From a1da9e434b66fbcd79b802027887822db37a516f Mon Sep 17 00:00:00 2001 From: zwj23 <3146838460@qq.com> Date: Fri, 7 Mar 2025 14:51:10 +0800 Subject: [PATCH] feat: add some new fast-api of flask. --- .dockerignore | 5 +++++ .gitignore | 20 ++++++++++++++++++++ Dockerfile | 30 ++++++++++++++++++++++++++++++ README.md | 172 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ auto.bat | 18 ++++++++++++++++++ bin/auto_api_doc.py | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ bin/restart_gunicron | 5 +++++ bin/start_gunicron | 4 ++++ bin/stop_gunicron | 6 ++++++ config/api/dev/flask.py | 45 +++++++++++++++++++++++++++++++++++++++++++++ config/api/pre/empty | 0 config/api/pre/flask.py | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ config/api/prod/flask.py | 47 +++++++++++++++++++++++++++++++++++++++++++++++ config/api/prod/gunicorn_config.py | 16 ++++++++++++++++ config/api/test/flask.py | 45 +++++++++++++++++++++++++++++++++++++++++++++ config/clickhouse_database_config_elec.py | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ config/default/env.yml | 1 + config/default/log.yml | 18 ++++++++++++++++++ demo/init_sqlite.py | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ demo/sanguo.db | Bin 0 -> 28672 bytes demo/shuihu.db | Bin 0 -> 12288 bytes doc/image/Sipaster_image_structure.png | Bin 0 -> 80826 bytes doc/image/Snipaste_dev_environment.png | Bin 0 -> 115713 bytes doc/image/Snipaste_prod_environment.png | Bin 0 -> 97289 bytes doc/image/Snipaste_pytest_unit_test.png | Bin 0 -> 106027 bytes doc/image/Snipaste_pytest_unit_test_environment.png | Bin 0 -> 104072 bytes doc/框架设计思想.md | 23 +++++++++++++++++++++++ docker_run.sh | 5 +++++ font/heiti-sc-light.ttf | Bin 0 -> 54428112 bytes requirements-dev.txt | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/app/auth.py | 17 +++++++++++++++++ src/app/data_model/elec_warn_clue_assign_records.py | 22 ++++++++++++++++++++++ src/app/data_model/elec_warn_clue_list.py | 19 +++++++++++++++++++ src/app/data_model/user_table.py | 24 ++++++++++++++++++++++++ src/app/v2/emergency_file_list.py | 34 ++++++++++++++++++++++++++++++++++ src/component/calc_time_list.py | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/component/device_warn_agg.py | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/component/fake_api_decorator.py | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/component/parse_excel_image.py | 186 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/font/simhei.ttf | Bin 0 -> 9753388 bytes src/framework/bootstrap/config_loader.py | 34 ++++++++++++++++++++++++++++++++++ src/framework/bootstrap/register_router.py | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/framework/constant/mimetype.py | 36 ++++++++++++++++++++++++++++++++++++ src/framework/constant/request_method.py | 10 ++++++++++ src/framework/constant/resp_code.py | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/framework/decorator/deprecated.py | 25 +++++++++++++++++++++++++ src/framework/decorator/synchronized.py | 15 +++++++++++++++ src/framework/environment.py | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/framework/exception.py | 9 +++++++++ src/framework/interceptor/clear_interceptor.py | 25 +++++++++++++++++++++++++ src/framework/interceptor/interceptor_loader.py | 47 +++++++++++++++++++++++++++++++++++++++++++++++ src/framework/interceptor/param_interceptor.py | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/framework/interface/abstract_after_request.py | 23 +++++++++++++++++++++++ src/framework/interface/abstract_api.py | 203 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/framework/interface/abstract_before_request.py | 21 +++++++++++++++++++++ src/framework/interface/abstract_view_model.py | 39 +++++++++++++++++++++++++++++++++++++++ src/framework/the_path.py | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/framework/util/api_util.py | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/framework/util/ding_talk_util.py | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/framework/util/request_json_encoder.py | 47 +++++++++++++++++++++++++++++++++++++++++++++++ src/framework/util/resp_util.py | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/framework/util/sql_db.py | 245 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/framework/vo/resp_result.py | 15 +++++++++++++++ src/framework/vo/view_object.py | 2 ++ src/runserver.py | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/add_test_key_model.py | 12 ++++++++++++ src/wsgi.py | 5 +++++ test/conftest.py | 19 +++++++++++++++++++ test/demo/test_api_framework_api.py | 25 +++++++++++++++++++++++++ test/demo/test_hello_sqldb.py | 31 +++++++++++++++++++++++++++++++ test/demo/test_hello_world.py | 23 +++++++++++++++++++++++ test/demo/test_upload_tmp_file.py | 35 +++++++++++++++++++++++++++++++++++ test/framework/bootstrap/test_config_loader.py | 35 +++++++++++++++++++++++++++++++++++ test/framework/constant/test_resp_code.py | 14 ++++++++++++++ test/framework/interceptor/test_interceptor_loader.py | 17 +++++++++++++++++ test/framework/test_the_path.py | 33 +++++++++++++++++++++++++++++++++ test/test_create_app.py | 12 ++++++++++++ 78 files changed, 3147 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 auto.bat create mode 100644 bin/auto_api_doc.py create mode 100644 bin/restart_gunicron create mode 100644 bin/start_gunicron create mode 100644 bin/stop_gunicron create mode 100644 config/api/dev/flask.py create mode 100644 config/api/pre/empty create mode 100644 config/api/pre/flask.py create mode 100644 config/api/prod/flask.py create mode 100644 config/api/prod/gunicorn_config.py create mode 100644 config/api/test/flask.py create mode 100644 config/clickhouse_database_config_elec.py create mode 100644 config/default/env.yml create mode 100644 config/default/log.yml create mode 100644 demo/init_sqlite.py create mode 100644 demo/sanguo.db create mode 100644 demo/shuihu.db create mode 100644 doc/image/Sipaster_image_structure.png create mode 100644 doc/image/Snipaste_dev_environment.png create mode 100644 doc/image/Snipaste_prod_environment.png create mode 100644 doc/image/Snipaste_pytest_unit_test.png create mode 100644 doc/image/Snipaste_pytest_unit_test_environment.png create mode 100644 doc/框架设计思想.md create mode 100644 docker_run.sh create mode 100644 font/heiti-sc-light.ttf create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 src/app/auth.py create mode 100644 src/app/data_model/elec_warn_clue_assign_records.py create mode 100644 src/app/data_model/elec_warn_clue_list.py create mode 100644 src/app/data_model/user_table.py create mode 100644 src/app/v2/emergency_file_list.py create mode 100644 src/component/calc_time_list.py create mode 100644 src/component/device_warn_agg.py create mode 100644 src/component/fake_api_decorator.py create mode 100644 src/component/parse_excel_image.py create mode 100644 src/font/simhei.ttf create mode 100644 src/framework/bootstrap/config_loader.py create mode 100644 src/framework/bootstrap/register_router.py create mode 100644 src/framework/constant/mimetype.py create mode 100644 src/framework/constant/request_method.py create mode 100644 src/framework/constant/resp_code.py create mode 100644 src/framework/decorator/deprecated.py create mode 100644 src/framework/decorator/synchronized.py create mode 100644 src/framework/environment.py create mode 100644 src/framework/exception.py create mode 100644 src/framework/interceptor/clear_interceptor.py create mode 100644 src/framework/interceptor/interceptor_loader.py create mode 100644 src/framework/interceptor/param_interceptor.py create mode 100644 src/framework/interface/abstract_after_request.py create mode 100644 src/framework/interface/abstract_api.py create mode 100644 src/framework/interface/abstract_before_request.py create mode 100644 src/framework/interface/abstract_view_model.py create mode 100644 src/framework/the_path.py create mode 100644 src/framework/util/api_util.py create mode 100644 src/framework/util/ding_talk_util.py create mode 100644 src/framework/util/request_json_encoder.py create mode 100644 src/framework/util/resp_util.py create mode 100644 src/framework/util/sql_db.py create mode 100644 src/framework/vo/resp_result.py create mode 100644 src/framework/vo/view_object.py create mode 100644 src/runserver.py create mode 100644 src/views/add_test_key_model.py create mode 100644 src/wsgi.py create mode 100644 test/conftest.py create mode 100644 test/demo/test_api_framework_api.py create mode 100644 test/demo/test_hello_sqldb.py create mode 100644 test/demo/test_hello_world.py create mode 100644 test/demo/test_upload_tmp_file.py create mode 100644 test/framework/bootstrap/test_config_loader.py create mode 100644 test/framework/constant/test_resp_code.py create mode 100644 test/framework/interceptor/test_interceptor_loader.py create mode 100644 test/framework/test_the_path.py create mode 100644 test/test_create_app.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8d01954 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.gitignore +.git* +venv +.run + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..30ea9eb --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# 忽略所有 .pyc 文件 +*.pyc + +# 忽略所有 .o 文件 +*.o + +# 忽略所有 .so 文件 +*.so + +# 忽略所有 .log 文件 +*.log + +# 忽略 .idea 目录 +.idea/ + +# 忽略所有的 __init__.py 文件 +**/__init__.py + +# 忽略 tmp 目录 +tmp/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f647264 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM harbor.airqualitychina.cn:2229/public/python:3.8.10-centos7.8 + +#ENV ENVIRONMENT prod +ENV PATH=$PATH:/data/deploy:/data/deploy/bin +WORKDIR /data/deploy +RUN mkdir -p /data/deploy/tmp/log + + +COPY requirements.txt ./ + +RUN pip3 install --upgrade pip -i https://pypi.tuna.tsinghua.edu.cn/simple \ +&& pip3 install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple + +RUN rm -f /etc/yum.repos.d/*repo \ +&& curl https://mirrors.aliyun.com/repo/Centos-7.repo -o /etc/yum.repos.d/Centos-7.repo 2>/dev/null + +RUN yum -y install crontabs +ADD cron /etc/cron.d/cron_docker +RUN chmod 0644 /etc/cron.d/cron_docker +RUN crontab /etc/cron.d/cron_docker + + +RUN yum install glibc-common -y +RUN localedef -c -f UTF-8 -i zh_CN zh_CN.utf8 +ENV LANG zh_CN.UTF-8 +ENV LC_ALL zh_CN.UTF-8 +COPY . . +RUN chmod +x -R /data/deploy/bin +EXPOSE 9091 +CMD [ "/bin/bash", "docker_run.sh" ] diff --git a/README.md b/README.md new file mode 100644 index 0000000..800ac5e --- /dev/null +++ b/README.md @@ -0,0 +1,172 @@ +# 子系统脚手架 + + +## 关于运行环境的说明 +1. 普通项目可以直接使用yard-base中的Dockerfile,有特殊需求的项目请自行修改Dockerfile中的镜像信息; +2. 开发环境推荐使用PyCharm作为IDE,运行项目如果报找不到src的错误,请在src上单击右键,选择设置为代码根目录即可 +3. 如果没有使用IDE,可以把src目录添加到环境变量中(如果只是运行,可以忽略此步骤,直接按第4步执行) +mac 添加环境变量 vi ~/bash profile 然后把src的绝对路径追加到PATH中即可; +Windows: 同理,把src的绝对路径添加PATH中; +4. 快速运行脚手架 +- 安装Python3.8 +- 执行git clone ssh://git@10.10.8.64:9922/root/yard-base.git +- cd yard-base +- python src/runserver.py + + +## 概览 + +### 从api-framework到yard-base + +第一版脚手架称为api-framework,功能如其名,意为用于api开发的框架; + +第二版脚手架称为yard-base,yard即庭院,base即基础。 + +从api-framework到yard-base主要做了如下变更。 + +- 重新整理目录结构,主要目录限定为bin、src、config、test、tmp; + +- 原来的Python 3.7.5 升级为Python 3.8.12; + + 原因:经常会用到pandas库,而pandas的某些功能对Python3.8以下支持不友好,而且Python3.8对协程支持更友好。 + +- 原来的Flask==1.1.2升级为Flask==2.0.2 + +- 把部分功能性代码从各个包中的``__init__.py``中迁移出来; + +- 添加自动注册路由和蓝图的功能; + +- 修改Docker镜像,新镜像基于官方python3.8-alpine3.15制作,镜像大小由原来的1.03G变为0.32G + +- 镜像内置脚手架所需环境,在没有特殊需求的情况下,完成镜像构建仅需3秒 + +- 优化数据库驱动型能,生成环境只采用mysqlclient,不再使用pymysql + +- 去掉framework中的DatetimeUtil.py,推荐使用Arrow,安装:pip install -U arrow + +### 目录结构 + + + +### 生产环境、测试环境和开发环境的控制 +prod:生产环境 +dev:开发环境 +test:测试环境,用于单元测试、预发测试。 + + + + + + + +### requirement说明 + +requirements-dev.txt 是开发环境的依赖,特点是安装顺利。 + +requirements.txt 是生产环境的依赖,特点是性能高。 + +### 开发服务器的运行方式 + +```shell +git clone ssh://git@10.10.8.64:9922/root/yard-default.git +pip install -r requirements-dev.txt +cd yard-default/src +python runserver.py +``` + +### Docker镜像说明 + +Dockerfile中所使用的镜像是harbor.airqualitychina.cn:2229/public/python3.8:flask-yard,330.88 MB python3.8:flask-yard镜像已经预装以下库: + +- Flask==2.0.2 +- Flask-RESTful==0.3.9 +- Flask-SQLAlchemy==2.5.1 +- Flask-Cors==3.0.10 +- mysqlclient==2.1.0 +- gevent==21.12.0 +- greenlet==1.1.2 +- gunicorn==20.1.0 +- PyYAML==6.0 +- demjson==2.2.4 +- jsonschema==4.2.1 +- requests==2.26.0 +- pytest==6.2.5 +- pandas==1.4.1 + +镜像构建速度测试:仅需3秒左右。 + +### 生产环境运行方式 + +生产环境使用Docker运行,数据库仅使用mysqlclient,不再支持pymysql,pymysql仅在开发环境中使用。 + +### 在Docker容器中,对gunicorn进行管理 + +```sh +/data/deploy/bin # ls +gunicorn.pid restart_gunicron start_gunicron stop_gunicron +/data/deploy/bin # +``` + +start_gunicron: 启动gunicorn + +stop_gunicron: 关闭gunicorn + +restart_gunicron:重启gunicorn + +### 如何执行单元测试 + +在项目根目录下,执行pytest即可。 + + + +## 快速上手 + +### 如何处理前端的get请求 + +假如前端发送的get请求的方式有如下两种: + +1)通过旧的json={}的形式: + +http://some-domain:9920/demo/json={"city":"beijing"}的get请求 + +2)把json放到body中 + +URL:http://some-domain:9920/demo + +格式:application/json + +请求方式:GET + +body: + +```json +{ + "city":"beijing" +} +``` + +后端代码: + +```python +class Demo(AbstractApi): + + def handle_get_request(self): + return self.get_params() +``` + +当前端发送请求时,将看到如下响应信息: + +```json +{ + "code": 2000, + "msg": "访问成功", + "result": { + "city": "beijing" + } +} +``` + + + + + diff --git a/auto.bat b/auto.bat new file mode 100644 index 0000000..739dd80 --- /dev/null +++ b/auto.bat @@ -0,0 +1,18 @@ +@echo off + +:loop +REM Run the python.py script +echo Running the Python script... +git pull +git push gitee +if %errorlevel% neq 0 ( + echo Error occurred while running the Python script. +) else ( + echo pull executed successfully. +) + +REM Wait for 1 minute +echo Waiting 1 minute before running the script again... +timeout /t 60 > nul + +goto loop \ No newline at end of file diff --git a/bin/auto_api_doc.py b/bin/auto_api_doc.py new file mode 100644 index 0000000..e4638f4 --- /dev/null +++ b/bin/auto_api_doc.py @@ -0,0 +1,78 @@ +""" +自动生成接口文档 +""" +import importlib +import inspect +import os +import re + +from docxtpl import DocxTemplate +from werkzeug.utils import find_modules + +from framework.interface.abstract_api import AbstractApi +from framework.the_path import ThePath + + +def get_friendly_type(type): + types = { + 'int': '整数', + 'str': '字符串', + 'float': '小数', + 'decimal': '小数', + } + return types.get(type, '字符串') + + +def _build_class_info_item(method, rule_data:dict, urls, cls_name, cls, title): + params = [] + for k, param in rule_data.items(): + param['name'] = k + param['_type'] = get_friendly_type(param['type']) + param['required'] = '必须' if param['required'] else '' + params.append(param) + return {'url': urls, 'cls_name': cls_name, 'cls': cls, 'title': title, 'method': method, 'params': params} + + +def _is_function_override(func): + return not str(func.__module__).endswith('abstract_api') + + +def get_class_info(): + class_info = [] + modules = find_modules('api.app', recursive=True) + for mod in modules: + package_name = mod.split('.')[2] + module = importlib.import_module(mod) + cls_list = inspect.getmembers(module, inspect.isclass) + + for cls_name, cls in cls_list: + if cls != AbstractApi and issubclass(cls, AbstractApi): + if not cls.url_names(): + slug = re.compile(r'([a-z]|\d)([A-Z])').sub(r'\1-\2', cls_name).lower() + urls = f"/{package_name}/{slug}" + else: + slugs = cls.url_names() + urls = [f"/{package_name}/{slug}" for slug in slugs] + + title = cls.__doc__.strip() + + if _is_function_override(cls.handle_get_request): + class_info.append(_build_class_info_item('GET', cls.get_param_rules, urls, cls_name, cls, title)) + if _is_function_override(cls.handle_post_request): + class_info.append(_build_class_info_item('POST', cls.get_param_rules, urls, cls_name, cls, title)) + + return class_info + + +def api_doc(project: str): + # FIXME 同一个接口有多个method,只能生成一个method的文档 + template_file = os.path.join(ThePath.doc(), 'tpl/api_template.docx') + tpl = DocxTemplate(template_file) + tpl.render({'project': project, 'items': get_class_info()}) + + save_file_path = os.path.join(ThePath.tmp('doc'), f'{project}.docx') + tpl.save(save_file_path) + + +if __name__ == '__main__': + api_doc('Yard-base演示项目') diff --git a/bin/restart_gunicron b/bin/restart_gunicron new file mode 100644 index 0000000..4cd8a89 --- /dev/null +++ b/bin/restart_gunicron @@ -0,0 +1,5 @@ +#!/usr/bin/env sh + +for i in `cat /data/deploy/bin/gunicorn.pid`;do + kill -HUP $i +done \ No newline at end of file diff --git a/bin/start_gunicron b/bin/start_gunicron new file mode 100644 index 0000000..0b3555a --- /dev/null +++ b/bin/start_gunicron @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +gunicorn --config=/data/deploy/config/api/prod/gunicorn_config.py wsgi:app +echo 'Started successfully!' +sleep infinity \ No newline at end of file diff --git a/bin/stop_gunicron b/bin/stop_gunicron new file mode 100644 index 0000000..3132090 --- /dev/null +++ b/bin/stop_gunicron @@ -0,0 +1,6 @@ +#!/usr/bin/env sh + +for i in `cat /data/deploy/bin/gunicorn.pid`;do + kill -9 $i +done +rm -f /data/deploy/bin/gunicorn.pid \ No newline at end of file diff --git a/config/api/dev/flask.py b/config/api/dev/flask.py new file mode 100644 index 0000000..654b291 --- /dev/null +++ b/config/api/dev/flask.py @@ -0,0 +1,45 @@ +""" +APP 配置 +""" +import urllib +from sqlalchemy.pool import QueuePool +from framework.the_path import ThePath + +ENV = 'development' +HOST = '0.0.0.0' +#HOST = '' +PORT = 9091 +DEBUG = True +SECRET_KEY = b'e\xa8n\xcf\xc3\xc9\xd9Y\xb1\xdd\xe4B\x95Xh\xeb' +SEND_FILE_MAX_AGE_DEFAULT = 43200 +import platform +if platform.system() == 'Linux': + SQLALCHEMY_DATABASE_URI = 'mysql://electricity_data:EF2zUl1GHss3yqay@192.168.195.202:3317/electricity_data', + SQLALCHEMY_BINDS = { + 'elec_db_2' : 'mysql+pymysql://electricity_data:EF2zUl1GHss3yqay@192.168.195.201:3317/electricity_data', + } +else: + SQLALCHEMY_DATABASE_URI = 'mysql://electricity_data:EF2zUl1GHss3yqay@10.10.31.166:33176/electricity_data' + SQLALCHEMY_BINDS = { + 'elec_db_2': 'mysql+pymysql://electricity_data:EF2zUl1GHss3yqay@10.10.31.166:33176/electricity_data', + + } + +SQLALCHEMY_ECHO = True +SQLALCHEMY_TRACK_MODIFICATIONS = False +SQLALCHEMY_RECORD_QUERIES = True +SQLALCHEMY_ENGINE_OPTIONS = { + 'connect_args': { + }, + 'echo_pool': True, + 'poolclass': QueuePool, + 'pool_pre_ping': True, + 'pool_size': 20, + 'pool_recycle': 300, + 'pool_timeout': 5 +} +_API_NICE_RESP=False +REDIS_HOST = 'electricity-service.redis.hotgrid.cn' +REDIS_PORT = 6379 +REDIS_PWD = 'phWOgP05ymg01GaR' +REDIS_DB = 2 diff --git a/config/api/pre/empty b/config/api/pre/empty new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/config/api/pre/empty diff --git a/config/api/pre/flask.py b/config/api/pre/flask.py new file mode 100644 index 0000000..113db84 --- /dev/null +++ b/config/api/pre/flask.py @@ -0,0 +1,50 @@ +""" +APP 配置 +""" +import urllib +from sqlalchemy.pool import QueuePool +from framework.the_path import ThePath + +ENV = 'development' +HOST = '0.0.0.0' +PORT = 9091 +DEBUG = True +# 新项目使用 python -c 'import os; print(os.urandom(16))' 生成SECRET_KEY +SECRET_KEY = b'e\xa8n\xcf\xc3\xc9\xd9Y\xb1\xdd\xe4B\x95Xh\xeb' +SEND_FILE_MAX_AGE_DEFAULT = 43200 + + +# SQLALCHEMY_DATABASE_URI = 'mysql://electricity:X1w4G3Hdl7VCNqhs@36.110.47.24:3317/electricity_data_test' +SQLALCHEMY_DATABASE_URI = 'mysql://electricity_api_service:GJlfh7&#jg@mmservice-05.mysql.hotgrid.cn:3306/electricity_data' +import platform +if platform.system() == 'Linux': + SQLALCHEMY_BINDS = { + 'elec_db_2' : 'mysql://electricity_api_service:GJlfh7&#jg@mmservice-05.mysql.hotgrid.cn:3306/electricity_data', + } +else: + SQLALCHEMY_BINDS = { + 'elec_db_2': 'mysql://electricity_data:EF2zUl1GHss3yqay@10.20.7.227:33176/electricity_data', + + } +SQLALCHEMY_ECHO = True +SQLALCHEMY_TRACK_MODIFICATIONS = False +SQLALCHEMY_RECORD_QUERIES = True +SQLALCHEMY_ENGINE_OPTIONS = { + 'connect_args': { + # 解决 sqlite3 线程报错问题 + # 'check_same_thread':False + }, + 'echo_pool': True, + 'poolclass': QueuePool, + 'pool_pre_ping': True, + 'pool_size': 20, + 'pool_recycle': 300, + 'pool_timeout': 5 +} + +_API_NICE_RESP=False + +REDIS_HOST = 'electricity-service.redis.hotgrid.cn' +REDIS_PORT = 6379 +REDIS_PWD = 'phWOgP05ymg01GaR' +REDIS_DB = 2 diff --git a/config/api/prod/flask.py b/config/api/prod/flask.py new file mode 100644 index 0000000..64e9eda --- /dev/null +++ b/config/api/prod/flask.py @@ -0,0 +1,47 @@ +""" +APP 配置 +""" +import platform +import urllib +from sqlalchemy.pool import QueuePool + +ENV = 'production' +HOST = '0.0.0.0' +PORT = 9091 +DEBUG = False +# 新项目使用 python -c 'import os; print(os.urandom(16))' 生成SECRET_KEY +SECRET_KEY = b'e\xa8n\xcf\xc3\xc9\xd9Y\xb1\xdd\xe4B\x95Xh\xeb' +SEND_FILE_MAX_AGE_DEFAULT = 43200 + +# 默认使用用电内网1的数据库,流水数据使用下面elec_db_2 +if platform.system() == 'Linux': + SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://electricity_data:EF2zUl1GHss3yqay@192.168.195.201:3317/electricity_data' + SQLALCHEMY_BINDS = { + 'elec_db_2' : 'mysql+pymysql://electricity_data:EF2zUl1GHss3yqay@192.168.195.201:3317/electricity_data', + + } +else: + SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://electricity_data:EF2zUl1GHss3yqay@10.20.7.227:33176/electricity_data' + SQLALCHEMY_BINDS = { + 'elec_db_2': 'mysql://electricity_data:EF2zUl1GHss3yqay@10.20.7.227:33176/electricity_data', + } + + +SQLALCHEMY_ECHO = False +SQLALCHEMY_TRACK_MODIFICATIONS = False +SQLALCHEMY_RECORD_QUERIES = True +SQLALCHEMY_ENGINE_OPTIONS = { + 'echo_pool': True, + 'poolclass': QueuePool, + 'pool_pre_ping': True, + 'pool_size': 20, + 'pool_recycle': 300, + 'pool_timeout': 5 +} + +_API_NICE_RESP = False + +REDIS_HOST = 'electricity-service.redis.hotgrid.cn' +REDIS_PORT = 6379 +REDIS_PWD = 'phWOgP05ymg01GaR' +REDIS_DB = 2 diff --git a/config/api/prod/gunicorn_config.py b/config/api/prod/gunicorn_config.py new file mode 100644 index 0000000..3cce275 --- /dev/null +++ b/config/api/prod/gunicorn_config.py @@ -0,0 +1,16 @@ +""" +https://docs.gunicorn.org/en/stable/ +""" +bind = '0.0.0.0:9091' +backlog = 512 +chdir = '/data/deploy/src/' +timeout = 150 +worker_class = 'gevent' +workers = 4 +threads = 2 +loglevel = 'info' +pidfile = "/data/deploy/bin/gunicorn.pid" +access_log_format = '%(t)s %(p)s %(h)s "%(r)s" %(s)s %(L)s %(b)s %(f)s" "%(a)s"' +accesslog = "/data/deploy/tmp/log/gunicorn_access.log" +errorlog = "/data/deploy/tmp/log/gunicorn_error.log" +daemon = True \ No newline at end of file diff --git a/config/api/test/flask.py b/config/api/test/flask.py new file mode 100644 index 0000000..d78f64a --- /dev/null +++ b/config/api/test/flask.py @@ -0,0 +1,45 @@ +""" +APP 配置 +""" +import urllib +from sqlalchemy.pool import QueuePool +from framework.the_path import ThePath + +ENV = 'development' +HOST = '0.0.0.0' +PORT = 9091 +DEBUG = True +# 新项目使用 python -c 'import os; print(os.urandom(16))' 生成SECRET_KEY +SECRET_KEY = b'e\xa8n\xcf\xc3\xc9\xd9Y\xb1\xdd\xe4B\x95Xh\xeb' +SEND_FILE_MAX_AGE_DEFAULT = 43200 + +# 数据库配置 +# SQLALCHEMY_DATABASE_URI = 'mysql://electricity:X1w4G3Hdl7VCNqhs@36.110.47.24:3317/electricity_data_test' +SQLALCHEMY_DATABASE_URI = 'mysql://electricity_api_service:GJlfh7&#jg@mmservice-05.mysql.hotgrid.cn:3306/electricity_data' +SQLALCHEMY_BINDS = { + 'olympic_db': f'mysql://olympic:{urllib.parse.quote("olympic_ysrd@2021")}@rm-2zem57smz83811158bo.mysql.rds.aliyuncs.com:3306/olympic_db', + 'ysrd_y': 'mysql://weiyanjie:ysrd_PASSW0RD_2023@ysrd_y_master.mysql.internal.airqualitychina.cn:3317/ysrd_y', + 'elec_db_2': 'mysql://electricity_api_service:GJlfh7&#jg@mmservice-05.mysql.hotgrid.cn:3306/electricity_data', + +} +SQLALCHEMY_ECHO = True +SQLALCHEMY_TRACK_MODIFICATIONS = False +SQLALCHEMY_RECORD_QUERIES = True +SQLALCHEMY_ENGINE_OPTIONS = { + 'connect_args': { + # 解决 sqlite3 线程报错问题 + # 'check_same_thread':False + }, + 'echo_pool': True, + 'poolclass': QueuePool, + 'pool_pre_ping': True, + 'pool_size': 20, + 'pool_recycle': 300, + 'pool_timeout': 5 +} +_API_NICE_RESP=False + +REDIS_HOST = 'electricity-service.redis.hotgrid.cn' +REDIS_PORT = 6379 +REDIS_PWD = 'phWOgP05ymg01GaR' +REDIS_DB = 2 diff --git a/config/clickhouse_database_config_elec.py b/config/clickhouse_database_config_elec.py new file mode 100644 index 0000000..e7da64d --- /dev/null +++ b/config/clickhouse_database_config_elec.py @@ -0,0 +1,65 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.sql import text +import time +import platform +import pandas as pd +class ClickHouseHelper: + def __init__(self): + if platform.system()=='Linux': + self.host = "192.168.253.195" + else: + self.host = "10.10.31.166" + self.port = 8123 + self.user = "default" + self.password = "Ysrd2024!" + self.db = "electrcity_data" + self.engine = None + self.Session = None + self._create_engine() + + def _create_engine(self): + # 使用 clickhouse+http:// 作为连接前缀 + connection_string = f"clickhouse+http://{self.user}:{self.password}@{self.host}:{self.port}/{self.db}" + # 配置连接池 + pool_size = 10 # 设置最大连接数量为10 + self.engine = create_engine( + connection_string, + pool_size=pool_size, + max_overflow=3000, # 设置最大溢出数量为0,即不允许超出 pool_size 的连接 + pool_timeout=30, # 设置连接池超时时间为30秒 + pool_recycle=-1 # 设置连接回收时间,-1 表示不回收 + ) + self.Session = sessionmaker(bind=self.engine) + + + def get_session(self): + # 获取数据库会话 + return self.Session() + + def _df_res_to_db(self, df, table_name): + df.to_sql( + name=table_name, + con=self.engine.connect(), + index=False, + chunksize=2000, + if_exists='append') + + def execute_select(self, sql): + time_start = time.time() + with self.engine.connect() as conn: + # 使用 text() 函数将 SQL 语句转换为可执行对象 + sql_text = text(sql) + result = conn.execute(sql_text) + data = [] + for row in result: + # 使用 row._mapping 将 RowProxy 转换为字典 + data.append(dict(row._mapping)) + return data + def execute_edit(self, sql): + with self.engine.connect() as conn: + sql_text = text(sql) + result = conn.execute(sql_text) + conn.commit() + return result.rowcount + diff --git a/config/default/env.yml b/config/default/env.yml new file mode 100644 index 0000000..65f186b --- /dev/null +++ b/config/default/env.yml @@ -0,0 +1 @@ +env: pre diff --git a/config/default/log.yml b/config/default/log.yml new file mode 100644 index 0000000..a1b964c --- /dev/null +++ b/config/default/log.yml @@ -0,0 +1,18 @@ +version: 1 +formatters: + common: + format: "[%(asctime)s] %(levelname)s -- %(filename)s %(funcName)s thread:%(thread)d [line:%(lineno)d]-> %(message)s" +handlers: + console: + class: logging.StreamHandler + level: DEBUG + formatter: common + stream: ext://sys.stdout + timed_rotating_file: + class: logging.handlers.TimedRotatingFileHandler + level: DEBUG + formatter: common + filename: /data/deploy/tmp/log/api.log +root: + level: DEBUG + handlers: [ console, timed_rotating_file ] \ No newline at end of file diff --git a/demo/init_sqlite.py b/demo/init_sqlite.py new file mode 100644 index 0000000..6dfcfa8 --- /dev/null +++ b/demo/init_sqlite.py @@ -0,0 +1,98 @@ +import pandas as pd +from sqlalchemy import create_engine +import datetime +import sys +import os +import threading + +sys.path.append(os.path.abspath(os.path.join(sys.path[0], '..'))) +from src.framework.the_path import ThePath + + +def run(): + # engine = create_engine('sqlite:///:memory:') + engine_sanguo = create_engine(f'sqlite:///{ThePath.demo()}/sanguo.db') + engine_shuihu = create_engine(f'sqlite:///{ThePath.demo()}/shuihu.db') + df1 = pd.DataFrame([ + { + 'id': ';', + 'created_at': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'updated_at': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'name': '张飞' + }, + { + 'id': 2, + 'created_at': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'updated_at': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'name': '张辽' + }, + { + 'id': 3, + 'created_at': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'updated_at': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'name': '刘备' + }, + { + 'id': 4, + 'created_at': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'updated_at': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'name': '关羽' + }, + ]) + df2 = pd.DataFrame([ + { + 'id': 1, + 'created_at': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'updated_at': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'name': '关圣' + }, + { + 'id': 1, + 'created_at': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'updated_at': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'name': '卢俊义' + }, + { + 'id': 1, + 'created_at': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'updated_at': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'name': '武松' + }, + { + 'id': 1, + 'created_at': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'updated_at': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'name': '鲁智深' + } + ] + ) + df1.to_sql(name=f't_sanguo', con=engine_sanguo, if_exists='replace') + df2.to_sql(name=f't_shuihu', con=engine_shuihu, if_exists='replace') + + result = pd.read_sql('select * from t_sanguo', con=engine_sanguo.connect()) + print(result) + + result = pd.read_sql('select * from t_shuihu', con=engine_shuihu.connect()) + print(result) + +def test_threading(): + + def test(id): + df = pd.DataFrame([ + { + 'id': id, + 'created_at': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'updated_at': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'name': str(id), + }, + ]) + engine = create_engine("mysql+pymysql://yard_base_test:AEWTC^&fhj13@10.10.40.220:3306/for_test") + with engine.begin() as connection: + df.to_sql(name=f't_user_for_test', con=connection, if_exists='append') + for i in range(50): + t = threading.Thread(target=test, args=(i,)) + t.start() + +if __name__ == '__main__': + run() + # test_threading() diff --git a/demo/sanguo.db b/demo/sanguo.db new file mode 100644 index 0000000..55e4508 Binary files /dev/null and b/demo/sanguo.db differ diff --git a/demo/shuihu.db b/demo/shuihu.db new file mode 100644 index 0000000..694e7c6 Binary files /dev/null and b/demo/shuihu.db differ diff --git a/doc/image/Sipaster_image_structure.png b/doc/image/Sipaster_image_structure.png new file mode 100644 index 0000000..3cccb4b Binary files /dev/null and b/doc/image/Sipaster_image_structure.png differ diff --git a/doc/image/Snipaste_dev_environment.png b/doc/image/Snipaste_dev_environment.png new file mode 100644 index 0000000..19f5281 Binary files /dev/null and b/doc/image/Snipaste_dev_environment.png differ diff --git a/doc/image/Snipaste_prod_environment.png b/doc/image/Snipaste_prod_environment.png new file mode 100644 index 0000000..932ce9b Binary files /dev/null and b/doc/image/Snipaste_prod_environment.png differ diff --git a/doc/image/Snipaste_pytest_unit_test.png b/doc/image/Snipaste_pytest_unit_test.png new file mode 100644 index 0000000..2a80ccf Binary files /dev/null and b/doc/image/Snipaste_pytest_unit_test.png differ diff --git a/doc/image/Snipaste_pytest_unit_test_environment.png b/doc/image/Snipaste_pytest_unit_test_environment.png new file mode 100644 index 0000000..cd65491 Binary files /dev/null and b/doc/image/Snipaste_pytest_unit_test_environment.png differ diff --git "a/doc/\346\241\206\346\236\266\350\256\276\350\256\241\346\200\235\346\203\263.md" "b/doc/\346\241\206\346\236\266\350\256\276\350\256\241\346\200\235\346\203\263.md" new file mode 100644 index 0000000..b4d1931 --- /dev/null +++ "b/doc/\346\241\206\346\236\266\350\256\276\350\256\241\346\200\235\346\203\263.md" @@ -0,0 +1,23 @@ +### 目的 +> 开发者在构建系统时会用到很多库,有很多选择与组合,为了统一、便于协同工作,减少沟通成本,特此创建本框架 +1. 轻松的创建api接口 +2. 轻松的创建定时任务 +3. 针对实际情况进行底层封装,在一定层度上保证编码的最佳实践 +4. 保证在一般情况下,通过此框架构建的系统能稳定运行,较少出错 + +### 目标用户 +1. 有Flask基础知识 +2. 会使用SQL查询数据 +3. 会用Python编写业务逻辑 + +### 不需要 +1. 不需要Flask的高级知识 +2. 大部分情况下不需要构建路由 +3. 不需要SQLAlchemy的高级知识 +4. 不需要定时任务框架(例如APScheduler、XXL-Job)的高级知识 + +### 兼容 +框架无法解决一切问题 +1. 兼容需要使用Flask高级功能的情况 +2. 兼容需要使用SQLAlchemy高级功能的情况 +3. 兼容需要使用定时任务框架高级功能的情况 \ No newline at end of file diff --git a/docker_run.sh b/docker_run.sh new file mode 100644 index 0000000..7580bc5 --- /dev/null +++ b/docker_run.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env sh +gunicorn --config=/data/deploy/config/api/prod/gunicorn_config.py wsgi:app +echo 'Started successfully!' +sleep infinity + diff --git a/font/heiti-sc-light.ttf b/font/heiti-sc-light.ttf new file mode 100644 index 0000000..0aef5c2 Binary files /dev/null and b/font/heiti-sc-light.ttf differ diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..891896d --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,70 @@ +aniso8601==9.0.1 +appdirs==1.4.4 +xlsxwriter==3.2.0 +async-timeout==4.0.2 +atomicwrites==1.4.1 +attrs==22.1.0 +certifi==2022.6.15 +charset-normalizer==2.0.12 +click==8.1.3 +colorama==0.4.5 +cycler==0.11.0 +docxcompose==1.3.5 +docxtpl==0.16.3 +et-xmlfile==1.1.0 +Flask==2.1.3 +Flask-Cors==3.0.10 +Flask-RESTful==0.3.9 +Flask-SQLAlchemy==2.5.1 +greenlet==1.1.3 +idna==3.3 +importlib-metadata==4.12.0 +importlib-resources==5.9.0 +iniconfig==1.1.1 +itsdangerous==2.1.2 +Jinja2==3.1.2 +jsonschema==4.2.1 +kiwisolver==1.4.4 +lxml==4.9.1 +MarkupSafe==2.1.1 +marshmallow==3.15.0 +matplotlib==3.4.2 +numpy==1.21.6 +openpyxl==3.0.10 +packaging==21.3 +pandas==1.3.5 +Pillow==9.4.0 +pluggy==1.0.0 +prettytable==3.6.0 +py==1.11.0 +pyecharts==2.0.2 +pyecharts-snapshot==0.2.0 +pyee==8.2.2 +PyMySQL==1.0.2 +pyparsing==3.0.9 +pyppeteer==1.0.2 +pyrsistent==0.18.1 +pytest==6.2.5 +python-dateutil==2.8.2 +python-docx==0.8.11 +pytz==2022.2.1 +PyYAML==6.0 +requests==2.28.0 +simplejson==3.18.4 +six==1.16.0 +snapshot-phantomjs==0.0.3 +SQLAlchemy==1.4.32 +toml==0.10.2 +tqdm==4.65.0 +typing_extensions>=4.6.1 +urllib3==1.26.12 +wcwidth==0.2.6 +websockets==10.4 +Werkzeug==2.0.2 +zipp==3.8.1 +DingtalkChatbot==1.5.3 +sqlacodegen +tablib +redis==3.2.0 +scikit-learn==1.3.2 +retry diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7175ef5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,80 @@ +aniso8601==9.0.1 +appdirs==1.4.4 +async-timeout==4.0.2 +atomicwrites==1.4.1 +attrs==22.1.0 +xlsxwriter==3.2.0 +certifi==2022.6.15 +charset-normalizer==2.0.12 +click==8.1.3 +colorama==0.4.5 +cycler==0.11.0 +docxcompose==1.3.5 +docxtpl==0.16.3 +et-xmlfile==1.1.0 +Flask==2.1.3 +Flask-Cors==3.0.10 +Flask-RESTful==0.3.9 +Flask-SQLAlchemy==2.5.1 +greenlet==1.1.3 +idna==3.3 +importlib-metadata==4.12.0 +importlib-resources==5.9.0 +iniconfig==1.1.1 +itsdangerous==2.1.2 +Jinja2==3.1.2 +jsonschema==4.2.1 +kiwisolver==1.4.4 +lxml==4.9.1 +MarkupSafe==2.1.1 +marshmallow==3.15.0 +matplotlib==3.7.5 +seaborn==0.12.2 +numpy==1.21.6 +openpyxl==3.0.10 +jieba +packaging==21.3 +pandas==1.3.5 +Pillow==9.4.0 +pluggy==1.0.0 +prettytable==3.6.0 +py==1.11.0 +pyecharts==2.0.2 +pyecharts-snapshot==0.2.0 +pyee==8.2.2 +PyMySQL==1.0.2 +pyparsing==3.0.9 +pyppeteer==1.0.2 +pyrsistent==0.18.1 +pytest==6.2.5 +python-dateutil==2.8.2 +python-docx==0.8.11 + +pytz==2022.2.1 +PyYAML==6.0 +requests==2.28.0 +simplejson==3.18.4 +six==1.16.0 +snapshot-phantomjs==0.0.3 +SQLAlchemy==1.4.47 +toml==0.10.2 +tqdm==4.65.0 +typing_extensions>=4.6.1 +urllib3==1.26.12 +wcwidth==0.2.6 +websockets==10.4 +Werkzeug==2.0.2 +zipp==3.8.1 +DingtalkChatbot==1.5.3 +sqlacodegen +tablib +redis==3.2.0 +tsmoothie==1.0.5 +arrow==1.3.0 +scikit-learn==1.3.2 +retry +asynch==0.2.4 +clickhouse-sqlalchemy==0.2.7 +clickhouse-driver==0.2.9 +geopandas==0.9.0 +shapely==2.0.7 \ No newline at end of file diff --git a/src/app/auth.py b/src/app/auth.py new file mode 100644 index 0000000..a5623ab --- /dev/null +++ b/src/app/auth.py @@ -0,0 +1,17 @@ +# -*- coding:utf-8 -*- +# @Author: Gaiting +# @Time: 2023/5/20 16:18 +# @Function: +from framework.util.sql_db import SqlDb +from app.data_model.user_table import ElecUser + +def auth(func): + def wrapper(self, userid, *args, **kwargs): #--》改了这里! + db_session = SqlDb().orm_session() + emp = db_session.query(ElecUser).filter(ElecUser.userid == userid).first() + try: + if emp.userid: pass + except Exception as e: + return -1 + return func(self, userid, *args, **kwargs) + return wrapper \ No newline at end of file diff --git a/src/app/data_model/elec_warn_clue_assign_records.py b/src/app/data_model/elec_warn_clue_assign_records.py new file mode 100644 index 0000000..9e617a3 --- /dev/null +++ b/src/app/data_model/elec_warn_clue_assign_records.py @@ -0,0 +1,22 @@ +# coding: utf-8 +from sqlalchemy import Column, DateTime, String, text +from sqlalchemy.dialects.mysql import INTEGER, TINYINT +from sqlalchemy.ext.declarative import declarative_base + +Base = declarative_base() +metadata = Base.metadata + + +class ElecWarnClueAssignRecord(Base): + __tablename__ = 'elec_warn_clue_assign_records' + __table_args__ = {'comment': '报警线索下发记录'} + + clue_code = Column(String(255), nullable=False) + from_user = Column(String(255), nullable=False) + created_at = Column(DateTime, server_default=text("CURRENT_TIMESTAMP")) + updated_at = Column(DateTime, server_default=text("CURRENT_TIMESTAMP")) + is_deleted = Column(TINYINT(4), server_default=text("'0'")) + id = Column(INTEGER(11), primary_key=True) + from_adm = Column(INTEGER(11), comment='发送该线索的管理机构id') + to_adm = Column(INTEGER(11), comment='接收该线索的管理机构id') + description = Column(String(1024), server_default=text("")) diff --git a/src/app/data_model/elec_warn_clue_list.py b/src/app/data_model/elec_warn_clue_list.py new file mode 100644 index 0000000..c045096 --- /dev/null +++ b/src/app/data_model/elec_warn_clue_list.py @@ -0,0 +1,19 @@ +# coding: utf-8 +from sqlalchemy import Column, DateTime, String, text +from sqlalchemy.dialects.mysql import TINYINT +from sqlalchemy.ext.declarative import declarative_base + +Base = declarative_base() +metadata = Base.metadata + + +class ElecWarnClueList(Base): + __tablename__ = 'elec_warn_clue_list' + __table_args__ = {'comment': '报警线索表'} + + code = Column(String(255), primary_key=True) + is_deleted = Column(TINYINT(4)) + is_closed = Column(TINYINT(4)) + created_at = Column(DateTime, server_default=text("CURRENT_TIMESTAMP")) + closed_time = Column(DateTime) + from_user = Column(String(255), nullable=False) diff --git a/src/app/data_model/user_table.py b/src/app/data_model/user_table.py new file mode 100644 index 0000000..08c82f2 --- /dev/null +++ b/src/app/data_model/user_table.py @@ -0,0 +1,24 @@ +# coding: utf-8 +from sqlalchemy import Column, DateTime, String, text +from sqlalchemy.dialects.mysql import INTEGER, TINYINT +from sqlalchemy.ext.declarative import declarative_base + +Base = declarative_base() +metadata = Base.metadata + +# sqlacodegen mysql://electricity_api_service:'GJlfh7&#jg'@mmservice-05.mysql.hotgrid.cn:3306/electricity_data?charset=utf8 --outfile=user_table.py --tables elec_user +class ElecUser(Base): + __tablename__ = 'elec_user' + __table_args__ = {'comment': '用电平台用户表'} + + userid = Column(String(255), primary_key=True) + user_name = Column(String(255), comment='用户名') + password = Column(String(255), comment='密码') + role_level = Column(TINYINT(4), comment='角色') + adm = Column(String(255), comment='用户归属的平台名称') + is_deleted = Column(TINYINT(4), server_default=text("'0'")) + created_at = Column(DateTime, server_default=text("CURRENT_TIMESTAMP")) + updated_at = Column(DateTime, server_default=text("CURRENT_TIMESTAMP")) + adm_id = Column(INTEGER(11), nullable=False, comment='用户所属管理机构id') + phone = Column(String(11), nullable=False, comment='手机号') + is_send = Column(INTEGER(1), nullable=False, comment='是否发送短信进行验证码验证,0:不用,1:发送') diff --git a/src/app/v2/emergency_file_list.py b/src/app/v2/emergency_file_list.py new file mode 100644 index 0000000..f396cff --- /dev/null +++ b/src/app/v2/emergency_file_list.py @@ -0,0 +1,34 @@ +# -*- coding:utf-8 -*- +# @Time : 2022/8/26 16:05 +import hashlib +import re +import json +import pandas as pd +import jieba +from framework.interface.abstract_api import AbstractApi +from marshmallow import Schema, fields + + +class EmergencyFileList(AbstractApi): + class GetSchema(Schema): + pass + + def handle_get_request(self): + search_sql_data = f""" + select + IFNULL(ep.start_time,"未绑定") as plan_time, + IFNULL(ecc.created_at,'--') as 'create_time', + IFNULL(ep.plan_name,"未绑定") as plan_name, + plan_id as plan_id, + file_name, + count(*) as couonts_ents, + IFNULL(ep.plan_type,"未绑定") as plan_type + from elec_control_center ecc + left join elec_plan ep on ep.id = ecc.plan_id and ep.is_deleted=0 + where file_name is not null + group by file_name +order by ecc.created_at desc; +; + """ + data_df = pd.DataFrame(self.sql_db.list(search_sql_data,"elec_db_2")) + return data_df.to_dict(orient='records') \ No newline at end of file diff --git a/src/component/calc_time_list.py b/src/component/calc_time_list.py new file mode 100644 index 0000000..f97bd0e --- /dev/null +++ b/src/component/calc_time_list.py @@ -0,0 +1,57 @@ +# -*- coding:utf-8 -*- +# @Time : 2023/7/25 16:00 +# @Author : Fushuyue +# @Owner : YSRD (Insights Value) +import datetime +from dateutil.rrule import rrule, MONTHLY +from dateutil.relativedelta import relativedelta + +def get_between_datetime(beginDate, endDate, type, format_str="%Y-%m-%d %H:%M:%S"): + dateList = [] + beginDate = datetime.datetime.strptime(beginDate, "%Y-%m-%d %H:%M:%S") + endDate = datetime.datetime.strptime(endDate, "%Y-%m-%d %H:%M:%S") + if type == 1: # 分钟 + while beginDate <= endDate: + dateStr = beginDate.strftime(format_str) + dateList.append(dateStr) + beginDate += relativedelta(minutes=15) + elif type == 2: # 小时 + while beginDate <= endDate: + dateStr = beginDate.strftime(format_str) + dateList.append(dateStr) + beginDate += relativedelta(hours=1) + elif type == 3: # 日 + while beginDate <= endDate: + dateStr = beginDate.strftime(format_str) + dateList.append(dateStr) + beginDate += relativedelta(days=1) + elif type == 4: # 月 + beginDate = datetime.datetime(beginDate.year, beginDate.month, 1) + while beginDate <= endDate: + dateStr = beginDate.strftime(format_str) + dateList.append(dateStr) + beginDate += relativedelta(months=1) + elif type == 5: # 季度 + quarters = list(rrule(MONTHLY, dtstart=beginDate, until=endDate, bymonth=(1, 4, 7, 10))) + return [i.strftime(format_str) for i in quarters] + # return [f'{i.year}年-第{(i.month - 1) // 3 + 1}季度' for i in quarters] + elif type == 6: # 年 + beginDate = datetime.datetime(beginDate.year, 1, 1) + while beginDate <= endDate: + dateStr = beginDate.strftime(format_str) + dateList.append(dateStr) + beginDate += relativedelta(years=1) + return dateList + + +def get_format_str(time_type): + if time_type == 1: # 刻钟 + return "%m-%d %H:%M" + elif time_type == 2: # 小时 + return '%m-%d %H' + elif time_type == 3: # 日 + return '%Y-%m-%d' + elif time_type in (4, 5): # 月 or 季度 + return '%Y-%m' + elif time_type == 6: # 年 + return '%Y' \ No newline at end of file diff --git a/src/component/device_warn_agg.py b/src/component/device_warn_agg.py new file mode 100644 index 0000000..23f8f37 --- /dev/null +++ b/src/component/device_warn_agg.py @@ -0,0 +1,74 @@ +# -*- coding:utf-8 -*- +# @Time : 2023/7/5 9:37 +# @Author : Fushuyue +# @Owner : YSRD (Insights Value) + +import pandas as pd + + +def calc_total_duration(warn_df): + days, hours, minutes, max_duration, max_duration_data = 0, 0, 0, 0, None + + def split_duration(x): + nonlocal days, hours, minutes, max_duration, max_duration_data + x_duration_minutes = 0 + duration = x['duration'] + if '天' in duration: + x_list = duration.split('天') + days += int(x_list[0]) + x_duration_minutes += int(x_list[0]) * 24 * 60 + duration = x_list[1] + if '小时' in duration: + x_list = duration.split('小时') + hours += int(x_list[0]) + x_duration_minutes += int(x_list[0]) * 60 + duration = x_list[1] + if '分' in duration: + x_list = duration.split('分') + minutes += int(x_list[0]) + x_duration_minutes += int(x_list[0]) + # 找出持续时长最大的一条报警 + if x_duration_minutes > max_duration: + max_duration_data = x.to_dict() + max_duration = x_duration_minutes + + warn_df.apply(func=split_duration, axis=1) + hours += minutes // 60 + minutes = minutes % 60 + days += hours // 24 + hours = hours % 24 + + duration = f'{minutes}分' + if hours: + duration = f'{hours}小时' + duration + if days: + duration = f'{days}天' + duration + + return duration, max_duration_data + + +def format_data(warn_list): + if not warn_list: + return list() + # 分组统计每个点位的:报警类型、最高报警级别、报警总时长、报警次数 + warn_level_map = {1: '一级', 2: '二级', 3: '三级'} + res = list() + for key, val_df in pd.DataFrame(warn_list).groupby( + by=["ent_id", "dev_id", "dev_name", "warn_type_id", "warn_type_name"]): + total_duration, max_duration_data = calc_total_duration(val_df) # 计算报警总持续时长和持续时长最大的一条报警 + res.append({ + 'ent_id': 0 if not key[0] else int(key[0]), + 'dev_id': 0 if not key[1] else int(key[1]), + 'dev_name': str(key[2]), # 报警设备 + 'warn_type_id': 0 if not key[3] else int(key[3]), # 报警类型 + 'warn_type_name': str(key[4]), # 报警类型 + 'warn_count': len(val_df), # 报警次数 + 'highest_level': warn_level_map[int(val_df['warn_level'].min())] if val_df['warn_level'].min() else 0, # 报警最高等级(一级为最高) + 'total_duration': total_duration, + 'warn_code': [str(i) for i in val_df['warn_code'].values], + 'start_time': str(val_df['warn_time'].min()), # 最早开始报警时间 + 'end_time': str(val_df['end_time'].max()), # 最晚结束报警时间 + 'warn_data': sorted(val_df.to_dict(orient='records'), key=lambda x: x['warn_time'], reverse=True), + 'max_duration_data': max_duration_data # 最大持续时长的一条报警信息 + }) + return res diff --git a/src/component/fake_api_decorator.py b/src/component/fake_api_decorator.py new file mode 100644 index 0000000..696c8e6 --- /dev/null +++ b/src/component/fake_api_decorator.py @@ -0,0 +1,52 @@ +# -*- coding:utf-8 -*- +# @Author: Gaiting +# @Time: 2022/9/12 17:40 +# @Function: + +from flask import request +import requests +from functools import wraps +from numpy import random + + +def fake_api(is_list=False): + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + suffix=args[1] if len(args)>=2 else "" + + fakeBaseUrl = "http://rap2api.taobao.org/app/mock/306541" + url = fakeBaseUrl + request.path + str(suffix) + rep = requests.request(method=request.method, url=url) + + if is_list: + return rep.json().get('data') + else: + # 自定义修改动态返回值 + if 'diy' in kwargs.keys() and kwargs['diy'] == True: + res = diy_response(kwargs, rep) + return res + return rep.json() + return rep.json() + return wrapper + return decorator + + +def diy_response(kwargs, rep): + if 'diy' in kwargs.keys() and kwargs['diy'] == True and kwargs['type'] == 'chart': + chart_data = {} + name = f'{kwargs["name"]}_chart_data' + value = list(map(lambda x: round(x * 100, 2), list(random.rand(24)))) + chart_data[name] = { + 'time': kwargs['time_list'], + 'data': value + } + res = rep.json() + res.update(chart_data) + return res + elif 'diy' in kwargs.keys() and kwargs['diy'] == True and kwargs['type'] == 'location': + value = list(map(lambda x: round(x * 100, 2), list(random.rand(24)))) + res = kwargs['data'] + for i in res: + i['value'] = value[res.index(i)] + return res diff --git a/src/component/parse_excel_image.py b/src/component/parse_excel_image.py new file mode 100644 index 0000000..1b164e5 --- /dev/null +++ b/src/component/parse_excel_image.py @@ -0,0 +1,186 @@ +import json +import requests +import xml.dom.minidom as xmldom +import os +import uuid +import zipfile +import shutil +from openpyxl import load_workbook + + + +def isfile_exist(file_path): + if not os.path.isfile(file_path): + print("It's not a file or no such file exist ! %s" % file_path) + return False + else: + return True + + +# 复制并修改指定目录下的文件类型名,将excel后缀名修改为.zip +def copy_change_file_name(file_path, new_type='.zip'): + if not isfile_exist(file_path): + return '' + extend = os.path.splitext(file_path)[1] # 获取文件拓展名 + if extend != '.xlsx' and extend != '.xls': + print("It's not a excel file! %s" % file_path) + return False + file_name = os.path.basename(file_path) # 获取文件名 + new_name = str(file_name.split('.')[0]) + new_type # 新的文件名,命名为:xxx.zip + dir_path = os.path.dirname(file_path) # 获取文件所在目录 + new_path = os.path.join(dir_path, new_name) # 新的文件路径 + if os.path.exists(new_path): + os.remove(new_path) + return shutil.copyfile(file_path, new_path) # 返回新的文件路径,压缩包 + +# 解压文件 +def unzip_file(zipfile_path): + if not isfile_exist(zipfile_path): + return False + if os.path.splitext(zipfile_path)[1] != '.zip': + print("It's not a zip file! %s" % zipfile_path) + return False + file_zip = zipfile.ZipFile(zipfile_path, 'r') + file_name = os.path.basename(zipfile_path) # 获取文件名 + zipdir = os.path.join(os.path.dirname(zipfile_path), str(file_name.split('.')[0])) # 获取文件所在目录 + for files in file_zip.namelist(): + file_zip.extract(files, zipdir) # 解压到指定文件目录 + file_zip.close() + return zipdir + +# 读取解压后的文件夹,打印图片路径 +def read_img(unzip_file_path): + img_dict = dict() + pic_dir = 'xl' + os.sep + 'media' # excel变成压缩包后,再解压,图片在media目录 + pic_path = os.path.join(unzip_file_path, pic_dir) # 解压后图片所在绝对目录 + file_list = os.listdir(pic_path) # 找到图片目录下的所有图片名称 + for file in file_list: + # img_url = os.path.join(pic_path, file) # 获取图片本地完整路径 + img_url = file_upload(img_path=os.path.join(pic_path, file)) # 将图片保存到服务服务器上 + img_dict[file] = img_url + return img_dict + +# 保存数据到服务器 +def file_upload(img_path): + data = {'subsystem_code': 'electricity-service', 'subsystem_api': 'elec-picture'} + file_name = str(uuid.uuid4()) + '.' + img_path.split('.')[-1] + try: + with open(img_path, 'rb') as f: + r = requests.post(url="https://api-file-manage.airqualitychina.cn:9998/api/hold-filename-upload", + data=data, + files=[('files', (file_name, f.read()))]) + file_url = "https://f.hotgrid.cn/" + json.loads(r.content)['result'][0]['url'] + print(file_url) + except Exception as e: + print(e) + file_url = '' + return file_url + +def get_img_pos_info(unzip_file_path, img_dict, drawing_idx): + """解析xml 文件,获取图片在excel表格中的索引位置信息""" + # 1. 查询rid与图片的关联关系 + xml_rel_dir = 'xl' + os.sep + 'drawings' + os.sep + '_rels' + os.sep + f'drawing{drawing_idx}.xml.rels' + xml_rel_path = os.path.join(unzip_file_path, xml_rel_dir) + img_info = parse_xml_rel(xml_rel_path) + + # 2. 解析xml 文件, 返回图片索引位置信息 + xml_dir = 'xl' + os.sep + 'drawings' + os.sep + f'drawing{drawing_idx}.xml' + xml_path = os.path.join(unzip_file_path, xml_dir) + image_info_dict = parse_xml(xml_path, img_dict, img_info) + return image_info_dict + +# 解析xml关联文件并获取对应图片位置 +def parse_xml_rel(file_name): + # 得到文档对象 + image_info = dict() + dom_obj = xmldom.parse(file_name) + # 得到元素对象 + element = dom_obj.documentElement + relationship = element.getElementsByTagName("Relationship") + for rel in relationship: + embed = rel.getAttribute('Id') + img = rel.getAttribute('Target').replace('../media/', '') + image_info[embed] = img + return image_info + + + +# 解析xml文件并获取对应图片位置 +def parse_xml(file_name, img_dict, img_info): + # 得到文档对象 + new_image_info = dict() + dom_obj = xmldom.parse(file_name) + # 得到元素对象 + element = dom_obj.documentElement + sub_twoCellAnchor = element.getElementsByTagName("xdr:twoCellAnchor") + if len(sub_twoCellAnchor) == 0: + sub_twoCellAnchor = element.getElementsByTagName("xdr:oneCellAnchor") + + for anchor in sub_twoCellAnchor: + # xdr_from = anchor.getElementsByTagName('xdr:from')[0] + try: + col = anchor.getElementsByTagName('xdr:col')[0].firstChild.nodeValue # 获取标签间的数据 + row = anchor.getElementsByTagName('xdr:row')[0].firstChild.nodeValue + embed = anchor.getElementsByTagName('a:blip')[0].getAttribute('r:embed') # 获取属性 + # col = xdr_from.childNodes[0].firstChild.data # 获取标签间的数据 + # row = xdr_from.childNodes[2].firstChild.data + # embed = \ + # anchor.getElementsByTagName('xdr:pic')[0].getElementsByTagName('xdr:blipFill')[0].getElementsByTagName( + # 'a:blip')[0].getAttribute('r:embed') # 获取属性 + if (int(row) + 1, int(col) + 1) in new_image_info: + new_image_info[(int(row) + 1, int(col) + 1)] += ',' + img_dict.get(img_info.get(embed, ''), '') + else: + new_image_info[(int(row) + 1, int(col) + 1)] = img_dict.get(img_info.get(embed, ''), '') + except Exception as e: + print(e) + + return new_image_info + +def parse_image(file): + # 先将文件保存到本地 + file_path = os.path.join(os.getcwd(), file.filename) + file.save(file_path) + # 0. 获取压缩包文件路径 + zip_file_path = copy_change_file_name(file_path) + if zip_file_path != '': + # 1. 获取解压文件路径 + unzip_file_path = unzip_file(zip_file_path) + if isinstance(unzip_file_path, str): + # 2. 获取解压目录下所有的图片信息 + img_dict = read_img(unzip_file_path) + import pandas as pd + df = pd.DataFrame(list(img_dict)) + # 3. 加载excel文件 + book = load_workbook(file_path) + drawing_idx = 1 # 用于累计drawing(i).xml.rels的数量 + # 4. 将每个sheet中的图片替换为图片的url + for sheet_name in book.sheetnames: + sheet = book[sheet_name] + # 判断sheet中是否有图片,若有再替换 + if hasattr(sheet, '_images') and sheet._images: + img_info_dict = get_img_pos_info(unzip_file_path, img_dict, drawing_idx=drawing_idx) + for key, val in img_info_dict.items(): + sheet.cell(row=key[0], column=key[1], value=val) + drawing_idx += 1 + + # 删除旧的excel文件,保存替换图片url后新的excel文件 + if os.path.exists(file_path): + os.remove(file_path) + book.save(file_path) + + # 删除解压路径 + if os.path.exists(unzip_file_path): + shutil.rmtree(unzip_file_path) + + # 删除压缩包 + if os.path.exists(zip_file_path): + os.remove(zip_file_path) + + return file_path + +if __name__ == '__main__': + # res = parse_image( + # file_path='C:\\Users\\admin\\Desktop\\reproject\\electricity-api-service\\src\\app\\v1\\file_upload\\顺义区用电企业数据问题(3).xlsx') + print(os.getcwd()) + + file_path = r'C:\Users\admin\Desktop\reproject\electricity-api-service\src\用电项目后台数据库录入用表.xlsx' diff --git a/src/font/simhei.ttf b/src/font/simhei.ttf new file mode 100644 index 0000000..5bd4687 Binary files /dev/null and b/src/font/simhei.ttf differ diff --git a/src/framework/bootstrap/config_loader.py b/src/framework/bootstrap/config_loader.py new file mode 100644 index 0000000..3c6c3eb --- /dev/null +++ b/src/framework/bootstrap/config_loader.py @@ -0,0 +1,34 @@ +# @Time : 2022-03-03 17:00:00 +# @Author : Gavin Jiang +# @Owner : YSRD (Insights Value) + +import os + +from framework.the_path import ThePath +from framework.environment import Environment + + +class ConfigLoader: + + def __init__(self, env=None): + # env的优先级,参数 > Dockerfile中的ENV + # self.env = os.getenv('ENVIRONMENT', 'prod') if not env else env + self.env = Environment.env() if not env else env + # 统一从配置文件里读,摒弃获取环境变量和start_up直接赋予参数的方式的方式? + self._data = { + 'log': ThePath.config(['default', 'log.yml']), + 'flask': ThePath.config(['api', self.env, 'flask.py']) + } + + def load(self): + env_log_path = ThePath.config(['api', self.env, 'log.yml']) + if os.path.exists(env_log_path): + self._data['log'] = env_log_path + + env_flask_path = ThePath.config(['api', self.env, 'flask.py']) + if os.path.exists(env_flask_path): + self._data['flask'] = env_flask_path + + @property + def data(self): + return self._data diff --git a/src/framework/bootstrap/register_router.py b/src/framework/bootstrap/register_router.py new file mode 100644 index 0000000..fb8f52f --- /dev/null +++ b/src/framework/bootstrap/register_router.py @@ -0,0 +1,126 @@ +# @Time : 2022-03-03 17:00:00 +# @Author : Gavin Jiang +# @Owner : YSRD (Insights Value) + +import importlib +import inspect +import os +import re + +from flask import Flask, Blueprint +from flask_restful import Api + +from framework.interface.abstract_api import AbstractApi +from framework.the_path import ThePath + + +class RegisterRouter: + + def __get_default_url_name(self, cls_name): + p = re.compile(r'([a-z]|\d)([A-Z])') + return re.sub(p, r'\1-\2', cls_name).lower() + + def __get_package_cls_data(self, file_name, package_name): + pkg_cls_data = [] + package_name = package_name.replace('\\', '/') + if file_name.endswith('.py') and not file_name.startswith('__init__'): + module = importlib.import_module(f"{package_name.replace('/','.')}.{file_name[:-3]}") + cls_list = inspect.getmembers(module, inspect.isclass) + for cls_name, cls in cls_list: + if cls != AbstractApi and issubclass(cls, AbstractApi): + pkg_cls_data.append({'pkg_name': package_name, 'cls_name': cls_name, 'cls': cls}) + + return pkg_cls_data + + def __register_api(self, cls_datum, restful_api: Api): + cls = cls_datum.get('cls') + url_names = getattr(cls, 'url_names')() + + if url_names: + if isinstance(url_names, str): + restful_api.add_resource(cls, f"/{url_names}") + else: + restful_api.add_resource(cls, *[f"/{url_name}" for url_name in url_names]) + else: + restful_api.add_resource(cls, f"/{self.__get_default_url_name(cls_datum.get('cls_name'))}") + + def create_init_file(self, path): + init_file = os.path.join(path, '__init__.py') + if not os.path.exists(init_file): + with open(os.path.join(path, '__init__.py'), 'a') as f: + f.write('') + + def register_router(self, app: Flask): + """ + @Time : 2022-05-24 12:00:00 + @Author : Feihong Wang + @Owner : YSRD (Insights Value) + + 曹科在运行pytest时, + 如果app下某个文件夹中没有__init__.py文件 + Blueprint(blueprint_name, package_name)会报错: + TypeError: expected str, bytes or os.PathLike object, not NoneType + 而执行runserver文件时不会如此 + + 梳理一遍原因: + Blueprint中获取root_path的关键代码: + + mod = sys.modules.get(import_name) + if mod is not None and hasattr(mod, "__file__"): + return os.path.dirname(os.path.abspath(mod.__file__)) + + if hasattr(loader, "get_filename"): + filepath = loader.get_filename(import_name) + + 原因是,pytest的每个测试文件都会通过contest.py执行create_app('test') + 所以会执行多次register_router函数 + 进而多次执行这个函数:Blueprint(blueprint_name, package_name), + 查看 Blueprint 源码,发现初始化时需要 运算root_path变量 + 第一次 Blueprint 检测到 package_name 未被import + sys.modules.get(package_name)返回None, + 最终Blueprint.root_path 会得到loader.get_filename(import_name),且不报错 + 接下来执行 self.__get_package_cls_data(file, package_name),模块被导入 + sys.modules.get(package_name)得到这种类型的数据: + 模块文件夹有__init__.py文件会获得这样的root_path: + <module 'app.demo' from '/Users/wangfeihong/Desktop/yard-base/src/app/demo/__init__.py'> + 如果文件夹中没有__init__.py会获得这样的root_path: + <module 'app.vtestt.action' (namespace)>, + 这种由于没有__init__.py文件也不会被self.__get_package_cls_data当成模块引入 + + 第二次再执行 Blueprint(blueprint_name, package_name), + 此时 package_name 已经被导入,mod = sys.modules.get(import_name)!=None, + root_path 为 os.path.dirname(os.path.abspath(mod.__file__)) + 由于 <module 'app.vtestt.action' (namespace)>(命名空间的模块,和普通模块的区别应该就是没有__init__.py) + 这种类型没有__file__属性,所以报错 TypeError: expected str, bytes or os.PathLike object, not NoneType + + 两种解决方法 + 1. Blueprint(blueprint_name, package_name, root_path=''),暂时不知道root_path有什么用,不过程序运行正常 + 2. 检测文件夹有没有__init__.py文件,如果没有则新建 + """ + app_path = ThePath.app() + src_path = ThePath.src() + + for dir_path, dir_names, files in os.walk(app_path): + + package_name = dir_path.replace(src_path, '').replace('\\', '.').replace('/', '.')[1:] + blueprint_name = dir_path.replace(app_path, '').replace('\\', '/') + if '__pycache__' in blueprint_name or blueprint_name == '': + continue + + self.create_init_file(dir_path) + blueprint = Blueprint(blueprint_name, package_name) + + restful_api = Api(blueprint) + + + for file in files: + pkg_cls_data = self.__get_package_cls_data(file, package_name) + + if len(pkg_cls_data) == 0: + continue + + for cls_datum in pkg_cls_data: + self.__register_api(cls_datum, restful_api) + + if len(restful_api.endpoints) > 0: + app.register_blueprint(blueprint, url_prefix=f"/{blueprint_name}") diff --git a/src/framework/constant/mimetype.py b/src/framework/constant/mimetype.py new file mode 100644 index 0000000..093e83b --- /dev/null +++ b/src/framework/constant/mimetype.py @@ -0,0 +1,36 @@ +# @Time : 2022-03-03 17:00:00 +# @Author : Gavin Jiang +# @Owner : YSRD (Insights Value) + +class MimeType: + + @staticmethod + def images(): + return ( + MimeType.IMAGE_BMP, + MimeType.IMAGE_JPG, + MimeType.IMAGE_GIF, + MimeType.IMAGE_TIFF, + MimeType.IMAGE_PNG + ) + + IMAGE_BMP = 'image/bmp' + IMAGE_JPG = 'image/jpeg' + IMAGE_GIF = 'image/gif' + IMAGE_TIFF = 'image/tiff' + IMAGE_PNG = 'image/png' + + AUDIO_MP4 = 'audio/mp4' + AUDIO_MPEG = 'audio/mpeg' + AUDIO_OGG = 'audio/ogg' + + TEXT_CSV = 'text/csv' + + VIDEO_MP4 = 'video/mp4' + VIDEO_OGG = 'video/ogg' + VIDEO_QUICKTIME = 'video/quicktime' + + APPLICATION_PDF = 'application/pdf' + APPLICATION_GZIP = 'application/gzip' + APPLICATION_MSWORD = 'application/msword' + APPLICATION_ZIP = 'application/zip' diff --git a/src/framework/constant/request_method.py b/src/framework/constant/request_method.py new file mode 100644 index 0000000..994eb5a --- /dev/null +++ b/src/framework/constant/request_method.py @@ -0,0 +1,10 @@ +# @Time : 2022-03-03 17:00:00 +# @Author : Gavin Jiang +# @Owner : YSRD (Insights Value) + +from enum import Enum + + +class RequestMethod(Enum): + GET = 'get', + POST = 'post' diff --git a/src/framework/constant/resp_code.py b/src/framework/constant/resp_code.py new file mode 100644 index 0000000..260d1c3 --- /dev/null +++ b/src/framework/constant/resp_code.py @@ -0,0 +1,67 @@ +# @Time : 2022-03-03 17:00:00 +# @Author : Gavin Jiang +# @Owner : YSRD (Insights Value) + +from enum import Enum, unique + + +@unique +class RespCode(Enum): + """ + 状态码常量类 + + 系统错误: 1000 + 正常:2000 + 通用异常:3000 + 数据异常:4000 + """ + + INNER_ERROR = 1001 + PLEASE_TRY_LATER = 1002 + + OK = 2000 + + TOKEN_INVALID = 3001 + TOKEN_EXPIRED = 3002 + PARAM_LACK = 3003 + PARAM_WRONG_FORMAT = 3004 + UNAUTHORIZED = 3005 + POST_REPEAT = 3007 + REQUEST_METHOD_INVALID = 3008 + INVALID_PARAM = 3009 + NOT_SUPPORT = 3010 + NOT_FOUND_DATA = 3011 + INVALID_OPERATION = 3012 + INVALID_JSON_FORMAT = 3013 + + DATA_ERROR = 4001 + NOT_FOUND_USER = 4004 + + def desc(self): + return _RespCodeDesc.desc_dict.get(self) + + +class _RespCodeDesc: + desc_dict = { + RespCode.INNER_ERROR: '内部错误', + RespCode.PLEASE_TRY_LATER: '操作失败,请稍后重试', + + RespCode.OK: "访问成功", + + RespCode.TOKEN_INVALID: '非法的token', + RespCode.TOKEN_EXPIRED: 'token已失效', + RespCode.PARAM_LACK: '缺失参数', + RespCode.PARAM_WRONG_FORMAT: '参数格式不正确', + RespCode.UNAUTHORIZED: '未授权', + RespCode.POST_REPEAT: '重复提交', + RespCode.REQUEST_METHOD_INVALID: '非法的请求方式', + RespCode.INVALID_PARAM: '非法的请求参数', + RespCode.NOT_SUPPORT: '不支持该操作', + RespCode.NOT_FOUND_DATA: '找不到数据', + RespCode.INVALID_OPERATION: '无效操作', + RespCode.INVALID_JSON_FORMAT: '错误的JSON格式', + + RespCode.DATA_ERROR: '数据错误', + + RespCode.NOT_FOUND_USER: 'user_code 未知' + } diff --git a/src/framework/decorator/deprecated.py b/src/framework/decorator/deprecated.py new file mode 100644 index 0000000..4d21500 --- /dev/null +++ b/src/framework/decorator/deprecated.py @@ -0,0 +1,25 @@ +# @Time : 2022/7/6 10:02 +# @Author : Gavin Jiang +# @Owner : YSRD (Insights Value) + +import logging + +def deprecated(func, after_version=None, help_msg=''): + """ + 用于提醒开发人员,使用的函数将会被废弃 + Args: + func: + after_version: 在某个版本后废弃 + help_msg: 用于告诉开发人员替代的方法 + + Returns: + + """ + def wrap_func(*args, **kwargs): + if after_version: + logging.warning(f"函数{func.__name__}将会在版本{after_version}后废弃,请谨慎使用!{help_msg}") + else: + logging.warning(f"函数{func.__name__}未来将会废弃,请谨慎使用!{help_msg}") + return func(*args, **kwargs) + + return wrap_func diff --git a/src/framework/decorator/synchronized.py b/src/framework/decorator/synchronized.py new file mode 100644 index 0000000..dbcb78b --- /dev/null +++ b/src/framework/decorator/synchronized.py @@ -0,0 +1,15 @@ +# @Time : 2022-03-03 17:00:00 +# @Author : Gavin Jiang +# @Owner : YSRD (Insights Value) + +import threading + + +def synchronized(func): + func.__lock__ = threading.Lock() + + def lock_func(*args, **kwargs): + with func.__lock__: + return func(*args, **kwargs) + + return lock_func diff --git a/src/framework/environment.py b/src/framework/environment.py new file mode 100644 index 0000000..fd5644e --- /dev/null +++ b/src/framework/environment.py @@ -0,0 +1,71 @@ +# @Time : 2022-03-03 17:00:00 +# @Author : Gavin Jiang +# @Owner : YSRD (Insights Value) + +import os + +import yaml + +from framework.the_path import ThePath +from framework.decorator.synchronized import synchronized + + +class Environment(object): + + @synchronized + def __new__(cls, *args, **kwargs): + if not hasattr(cls, '_instance'): + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + self.env = os.getenv('ENVIRONMENT', 'prod') + # with open(ThePath.config(['default', 'env.yml'])) as f: + # self._config = yaml.load(f,Loader=yaml.FullLoader) + + @staticmethod + def is_prod() -> bool: + return Environment().env == 'prod' + + @staticmethod + def is_pre() -> bool: + return Environment().env == 'pre' + + @staticmethod + def is_dev() -> bool: + return Environment().env == 'dev' + + @staticmethod + def env() -> str: + return Environment().env + + +# class Environment(object): +# +# @synchronized +# def __new__(cls, *args, **kwargs): +# if not hasattr(cls, '_instance'): +# cls._instance = super().__new__(cls) +# return cls._instance +# +# def __init__(self): +# with open(ThePath.config(['default', 'env.yml'])) as f: +# self._config = yaml.load(f,Loader=yaml.FullLoader) +# +# @staticmethod +# def is_prod() -> bool: +# return Environment()._config.get('env') == 'prod' +# +# @staticmethod +# def is_pre() -> bool: +# return Environment()._config.get('env') == 'pre' +# +# @staticmethod +# def is_dev() -> bool: +# return Environment()._config.get('env') == 'dev' +# +# @staticmethod +# def env() -> str: +# return Environment()._config.get('env') + +# print(Environment.is_prod()) \ No newline at end of file diff --git a/src/framework/exception.py b/src/framework/exception.py new file mode 100644 index 0000000..b7c06d1 --- /dev/null +++ b/src/framework/exception.py @@ -0,0 +1,9 @@ +# @Time : 2022-03-03 17:00:00 +# @Author : Gavin Jiang +# @Owner : YSRD (Insights Value) + +class UnimplementedException(Exception): + """ + 未实现异常 + """ + pass \ No newline at end of file diff --git a/src/framework/interceptor/clear_interceptor.py b/src/framework/interceptor/clear_interceptor.py new file mode 100644 index 0000000..e1cbf9b --- /dev/null +++ b/src/framework/interceptor/clear_interceptor.py @@ -0,0 +1,25 @@ +import os +# @Time : 2022-03-03 17:00:00 +# @Author : Gavin Jiang +# @Owner : YSRD (Insights Value) + +from flask import Response, g + +from framework.interface.abstract_after_request import AbstractAfterRequest + + +class ClearInterceptor(AbstractAfterRequest): + + @staticmethod + def priority(): + return 10 + + def handle(self, resp: Response) -> Response: + if hasattr(g, 'upload_tmp_files'): + for item in g.upload_tmp_files: + tmp_file_path = item.get('tmp_file_path') + if item.get('clear_after_request', False) and os.path.exists(tmp_file_path): + os.remove(tmp_file_path) + + return resp + diff --git a/src/framework/interceptor/interceptor_loader.py b/src/framework/interceptor/interceptor_loader.py new file mode 100644 index 0000000..0ba14e3 --- /dev/null +++ b/src/framework/interceptor/interceptor_loader.py @@ -0,0 +1,47 @@ +# @Time : 2022-03-03 17:00:00 +# @Author : Gavin Jiang +# @Owner : YSRD (Insights Value) + +import importlib +import inspect +import os + +from framework.interface.abstract_after_request import AbstractAfterRequest +from framework.interface.abstract_before_request import AbstractBeforeRequest +from framework.the_path import ThePath + + +class InterceptorLoader: + INTERCEPTOR_PKG_NAME = 'interceptor' + + @staticmethod + def __get_file_instance_list(file_name, package_name): + before_list = [] + after_list = [] + if file_name.endswith('.py') and not file_name.startswith('__init__'): + module = importlib.import_module(f"{package_name}.{file_name[:-3]}") + cls_list = inspect.getmembers(module, inspect.isclass) + for cls_name, cls in cls_list: + if cls != AbstractBeforeRequest and issubclass(cls, AbstractBeforeRequest): + before_list.append(cls()) + if cls != AbstractAfterRequest and issubclass(cls, AbstractAfterRequest): + after_list.append(cls()) + + return before_list, after_list + + @staticmethod + def load_interceptors(): + src_path = ThePath.src() + + before_list = [] + after_list = [] + for dir_path, dir_names, files in os.walk(src_path): + dir_path = dir_path.replace('\\','/') + if InterceptorLoader.INTERCEPTOR_PKG_NAME in dir_names: + pkg_name = f"{dir_path.replace(src_path, '').replace('/', '.')[1:]}.{InterceptorLoader.INTERCEPTOR_PKG_NAME}" + for file in os.listdir(os.path.join(dir_path, InterceptorLoader.INTERCEPTOR_PKG_NAME)): + in_file_before_list, in_file_after_list = InterceptorLoader.__get_file_instance_list(file, pkg_name) + before_list = before_list + in_file_before_list + after_list = after_list + in_file_after_list + + return sorted(before_list, key=lambda x: x.priority(), reverse=True), sorted(after_list, key=lambda x: x.priority(), reverse=True) diff --git a/src/framework/interceptor/param_interceptor.py b/src/framework/interceptor/param_interceptor.py new file mode 100644 index 0000000..2d2e7a5 --- /dev/null +++ b/src/framework/interceptor/param_interceptor.py @@ -0,0 +1,50 @@ +# @Time : 2022-03-03 17:00:00 +# @Author : Gavin Jiang +# @Owner : YSRD (Insights Value) + +import json + +from flask import request, g + +from framework.interface.abstract_before_request import AbstractBeforeRequest + + +class ParamInterceptor(AbstractBeforeRequest): + + @staticmethod + def priority(): + return 10 + + def handle(self): + # ParamInterceptor.__fill_params_to_g() + pass + + @staticmethod + def __fill_params_to_g(): + params = {} + for key, value in request.args.items(): + if key == 'json': + data = json.loads(value) + if isinstance(data, dict): + params.update(data) + else: + params['error'] = '不是合法的json' + raise ValueError('不是合法的json') + else: + params[key] = ParamInterceptor.__decode_if_json(value) + # request.json: body中的raw,格式为application/json的数据 + if request.json: + params.update(request.json) + # request.form: body中的form-data数据 + if request.form: + params.update(request.form) + + g.params = params + + @staticmethod + def __decode_if_json(value: str): + try: + data = json.loads(value) + return data + except ValueError: + return value diff --git a/src/framework/interface/abstract_after_request.py b/src/framework/interface/abstract_after_request.py new file mode 100644 index 0000000..70ce95b --- /dev/null +++ b/src/framework/interface/abstract_after_request.py @@ -0,0 +1,23 @@ +# @Time : 2022-03-03 17:00:00 +# @Author : Gavin Jiang +# @Owner : YSRD (Insights Value) + +import abc +from abc import ABCMeta + +from flask import Response + + +class AbstractAfterRequest(metaclass=ABCMeta): + + @abc.abstractmethod + def handle(self, resp: Response) -> Response: + return resp + + @staticmethod + def priority(): + """ + 执行优先级,从大到小执行 + @return: + """ + return 0 diff --git a/src/framework/interface/abstract_api.py b/src/framework/interface/abstract_api.py new file mode 100644 index 0000000..da4ea67 --- /dev/null +++ b/src/framework/interface/abstract_api.py @@ -0,0 +1,203 @@ +# @Time : 2022-03-03 17:00:00 +# @Author : Gavin Jiang +# @Owner : YSRD (Insights Value) + +import os +import re +import json +import logging + +from flask import g, Response, request, current_app +import redis +from flask_restful import Resource +from jsonschema.exceptions import ValidationError + +from framework.constant.request_method import RequestMethod +from framework.constant.resp_code import RespCode +from framework.decorator.deprecated import deprecated +from framework.interface.abstract_view_model import AbstractViewModel +from framework.util.api_util import ApiUtil +from framework.util.request_json_encoder import RequestJsonEncoder +from framework.util.resp_util import RespUtil +from framework.util.sql_db import SqlDb +from framework.vo.resp_result import RespResult + +from werkzeug import exceptions +import werkzeug +from marshmallow import Schema, fields, ValidationError + + +class AbstractApi(Resource): + """ + API抽象类,所有api都应继承此类 + """ + + # 定义参数规则 + + class GetSchema(Schema): + pass + + class PostSchema(Schema): + pass + + def redis_conn(self): + return redis.Redis( + host=current_app.config['REDIS_HOST'], + port=current_app.config['REDIS_PORT'], + password=current_app.config['REDIS_PWD'], + db=current_app.config['REDIS_DB']) + + def get_domain(self): + domain_map = { + 'pre': 'https://electricity-api-service-pre.airqualitychina.cn', + 'test': 'http://electricity-api-service.test.hotgrid.cn', + # 'test': 'https://electricity-api-service-test.airqualitychina.cn', # 已弃用 + 'prod': 'http://electricity-api-service.bjmemc.hotgrid.cn' + } + if os.getenv('ENV'): + domain = domain_map[os.getenv('ENV')] + else: + from flask import request + domain = request.host_url # 本地运行 + return domain + + def __init__(self, *args, **kwargs): + self.sql_db = SqlDb() + self.redis = self.redis_conn() + self._logger = logging.getLogger(__name__) + self.domain = self.get_domain() # 获取当前环境的域名 + + super().__init__(*args, **kwargs) + + def decode_params(self, schema): + """ + 接收请求参数, 兼容?json={...} 和 key=value 格式 + 1、 请求参数中有json字段 + (1)schema中有json字段,则接收json字段给schema进行验证 + (2)schema中没有json字段,则接收json中的字段给schema进行验证 + 2、请求参数中没有json字段 + (1)接收所有请求参数给schema进行验证 + """ + if request.method == "GET": + if 'json' in request.values: + if 'json' in schema._declared_fields.keys(): + params = request.values + else: + params = request.args.get("json") + params = re.sub('\'', '\"', params) + params = json.loads(params) + else: + params = dict(request.values) + else: + if request.json: + params = request.get_json() + else: + params = dict(request.values) + return params + + def __handle_and_check_params(self): + if request.method == 'GET': + schema = self.GetSchema + else: + schema = self.PostSchema + + params = self.decode_params(schema) + # params = request.values + # params = {} + # try: + # params = request.json + # # 以json为格式的请求 + # except: + # if request.values: + # params = request.values + # if params == None: + # """ + # 这里因为在k8s环境上 + # g.params = schema().load(params)无法处理params=None的情况 + # 会报错 '_schema': ['Invalid input type.'] + # 目前不知道是哪个包的版本问题,所以暂时用这个方法解决 + # """ + # params = {} + + try: + g.params = schema().load(params) + except ValidationError as error: + self._logger.warning(str(error)) + return RespUtil.invalid_param(str(error)) + + @staticmethod + def url_names(): + return None + + def get(self): + return self.__handle_and_resp(RequestMethod.GET) + + def post(self): + return self.__handle_and_resp(RequestMethod.POST) + + def __handle_and_resp(self, method: RequestMethod): + # 先对请求进行参数校验,非法则返回,后续添加报错msg + check_params_result = self.__handle_and_check_params() + if isinstance(check_params_result, RespResult): + return check_params_result.build() + + try: + if method == RequestMethod.POST: + if current_app.config['_API_NICE_RESP']: + result = self.nice_handle_post_req() + else: + result = self.handle_post_request() + if result is None: + result = {} + else: + if current_app.config['_API_NICE_RESP']: + result = self.nice_handle_get_req() + else: + result = self.handle_get_request() + + if isinstance(result, RespResult): + return result.build() + + if isinstance(result, Response): + return result + if isinstance(result, werkzeug.wrappers.response.Response): + return result + return RespUtil.ok(json.loads(json.dumps(result, cls=RequestJsonEncoder))).build() + except ValidationError as e: + self._logger.error(RespCode.INVALID_JSON_FORMAT.desc(), exc_info=True) + return RespUtil.invalid_json_format(e.args[0]).build() + except Exception as e: + self._logger.error("内部异常", exc_info=True) + return RespUtil.inner_error(e.args[0]).build() + + @deprecated + def handle_get_request(self): + return RespUtil.request_method_invalid() + + @deprecated + def handle_post_request(self): + return RespUtil.request_method_invalid() + + def nice_handle_get_req(self) -> AbstractViewModel: + log = f'{self.__class__.__name__}类nice_handle_get_req方法未重写' + logging.error(log) + raise Exception(log) + + def nice_handle_post_req(self) -> AbstractViewModel: + log = f'{self.__class__.__name__}类nice_handle_post_req方法未重写' + logging.error(log) + raise Exception(f'{self.__class__.__name__}类nice_handle_post_req方法未重写') + + @staticmethod + def get_params(): + if hasattr(g, 'params'): + return g.params + return {} + + @staticmethod + def get_param(key, default=None): + return AbstractApi.get_params().get(key, default) + + @staticmethod + def tmp_save_req_files(allowed_mimetypes=None, clear_after_request=True): + return ApiUtil.tmp_save_req_files(allowed_mimetypes, clear_after_request) diff --git a/src/framework/interface/abstract_before_request.py b/src/framework/interface/abstract_before_request.py new file mode 100644 index 0000000..90abad8 --- /dev/null +++ b/src/framework/interface/abstract_before_request.py @@ -0,0 +1,21 @@ +# @Time : 2022-03-03 17:00:00 +# @Author : Gavin Jiang +# @Owner : YSRD (Insights Value) + +import abc +from abc import ABCMeta + + +class AbstractBeforeRequest(metaclass=ABCMeta): + + @abc.abstractmethod + def handle(self): + pass + + @staticmethod + def priority(): + """ + 执行优先级,从大到小执行 + @return: + """ + return 0 diff --git a/src/framework/interface/abstract_view_model.py b/src/framework/interface/abstract_view_model.py new file mode 100644 index 0000000..17b76db --- /dev/null +++ b/src/framework/interface/abstract_view_model.py @@ -0,0 +1,39 @@ +# @Time : 2022/7/6 10:32 +# @Author : Gavin Jiang +# @Owner : YSRD (Insights Value) +import logging +from abc import ABCMeta + +from flask import current_app + +from framework.exception import UnimplementedException + + +class AbstractViewModel(metaclass=ABCMeta): + """ + 视图模型 + """ + + def __init__(self, raw_result): + self._logger = logging.getLogger(__name__) + self.raw_result = raw_result + self.result = self.nice_result() + + def nice_result(self): + if current_app.config['_API_NICE_RESP']: + return self.process(self.raw_result) + + self._logger.warning(f"未实现process方法,view model层不起作用!") + return self.raw_result + + def process(self, raw_result): + """ + 之类应重写该方法以返回良好的结果 + Args: + raw_result: + + Returns: + + """ + self._logger.error("需实现__process()函数以对返回给前端的结果进行处理!!!") + raise UnimplementedException \ No newline at end of file diff --git a/src/framework/the_path.py b/src/framework/the_path.py new file mode 100644 index 0000000..c623b0b --- /dev/null +++ b/src/framework/the_path.py @@ -0,0 +1,70 @@ +# @Time : 2022-03-03 17:00:00 +# @Author : Gavin Jiang +# @Owner : YSRD (Insights Value) + +import os +from abc import ABCMeta +from collections.abc import Iterable + + +class ThePath(metaclass=ABCMeta): + + @staticmethod + def root(): + return os.path.dirname(os.path.dirname(os.path.dirname(__file__))).replace('\\', '/') + + @staticmethod + def src(): + return os.path.join(ThePath.root(), 'src').replace('\\', '/') + + @staticmethod + def test(): + return os.path.join(ThePath.root(), 'test').replace('\\', '/') + + @staticmethod + def app(): + return os.path.join(ThePath.src(), 'app').replace('\\', '/') + + @staticmethod + def bin(): + return os.path.join(ThePath.root(), 'bin').replace('\\', '/') + + @staticmethod + def demo(): + return os.path.join(ThePath.root(), 'demo').replace('\\', '/') + + @staticmethod + def config(path=None): + config_path = os.path.join(ThePath.root(), 'config') + if path: + if isinstance(path, str): + return os.path.join(config_path, path) + elif isinstance(path, Iterable): + for p in path: + config_path = os.path.join(config_path, p) + return config_path + + return config_path.replace('\\', '/') + + @staticmethod + def tmp(path=None): + root = ThePath.root() + + tmp_path = os.path.join(root, 'tmp') + if path: + result = os.path.join(tmp_path, path) + else: + result = tmp_path + + if not os.path.exists(result): + os.makedirs(result) + + return result.replace('\\', '/') + + @staticmethod + def doc(): + return os.path.join(ThePath.root(), 'doc').replace('\\', '/') + + @staticmethod + def font(name): + return os.path.join(ThePath.root(), f'font/{name}').replace('\\', '/') diff --git a/src/framework/util/api_util.py b/src/framework/util/api_util.py new file mode 100644 index 0000000..18f8465 --- /dev/null +++ b/src/framework/util/api_util.py @@ -0,0 +1,90 @@ +# @Time : 2022-03-03 17:00:00 +# @Author : Gavin Jiang +# @Owner : YSRD (Insights Value) + +import os +import uuid + +from flask import request, g + +from framework.the_path import ThePath + + +class ApiUtil: + + @staticmethod + def tmp_save_req_files(allowed_mimetypes=None, clear_after_request=True): + """ + 把本次请求中上传的文件存到临时目录中 + @param allowed_mimetypes: + 允许上传的mimetype,可在api/framework/constant/mimetype.py中找到定义好的值,如果为None,则所有类型的文件都将被保存到临时目录中 + @param clear_after_request: + 是否在请求结束后自动删除临时文件,True表示删除 + @return: + 返回一个列表,列表中以dict的方式返回每个上传文件的: + 1. 原始名称(filename) + 2. 临时文件名(tmp_filename) + 3. 临时文件路径(tmp_file_path) + 4. 文件大小(size) + """ + results = [] + + if not request.files: + return results + + file_path = ThePath.tmp('post_files') + + file_storage_list = request.files + for key in file_storage_list.keys(): + for file_storage in file_storage_list.getlist(key): + filename = file_storage.filename + mimetype = file_storage.mimetype + + if allowed_mimetypes and mimetype not in allowed_mimetypes: + continue + + tmp_filename = str(uuid.uuid4()) + tmp_file_path = os.path.join(file_path, tmp_filename).replace('\\', '/') + file_storage.save(tmp_file_path) + + results.append({ + 'filename': filename, + 'tmp_filename': tmp_filename, + 'tmp_file_path': tmp_file_path, + 'size': os.path.getsize(tmp_file_path), + 'clear_after_request': clear_after_request + }) + + g.upload_tmp_files = results + + return results + + @staticmethod + def get_raw_file_storage_list(allowed_mimetypes=None): + """ + 获取本次请求中上传文件的原始FileStorage列表 + @param allowed_mimetypes: 允许上传的mimetype,可在api/framework/constant/mimetype.py中找到定义好的值 + @return: 上传文件的原始FileStorage列表 + """ + results = [] + if not request.files: + return results + + file_storage_list = request.files + for key in file_storage_list.keys(): + for file_storage in file_storage_list.getlist(key): + mimetype = file_storage.mimetype + + if allowed_mimetypes and mimetype not in allowed_mimetypes: + continue + + results.append(file_storage) + + return results + + @staticmethod + def keep_decimals(line, fields,number=2): + print(line) + for i in fields: + line[i] = round(line[i], number) + return line \ No newline at end of file diff --git a/src/framework/util/ding_talk_util.py b/src/framework/util/ding_talk_util.py new file mode 100644 index 0000000..91e9c5b --- /dev/null +++ b/src/framework/util/ding_talk_util.py @@ -0,0 +1,58 @@ +# @Time : 2022-03-03 17:00:00 +# @Author : Gavin Jiang +# @Owner : YSRD (Insights Value) + +import base64 +import hashlib +import hmac +import logging +from urllib import parse +import time + +import requests +from configparser import ConfigParser + + +class DingTalkUtil: + """ + 发送钉钉机器人消息 + """ + + def __init__(self, config_file_path): + self._logger = logging.getLogger(__name__) + + cfg = ConfigParser() + cfg.read(config_file_path) + + self.cfg = cfg + + def send_msg(self, msg, mobiles: list = None, section='basic', is_at_all=0): + if mobiles is None: + mobiles = str(self.cfg.get(section, 'mobiles')).split(',') + + timestamp = str(round(time.time() * 1000)) + + at = {"isAtAll": is_at_all} + if isinstance(mobiles, list) and len(mobiles) > 0: + at['atMobiles'] = mobiles + + params = { + "msgtype": "text", "text": {"content": msg}, "at": at + } + + url = f"{self.cfg.get(section, 'url')}×tamp={timestamp}&sign={self.__generate_sign(timestamp, section)}" + + resp = requests.post(url=url, json=params) + + self._logger.info(f"Send msg successfully, resp: {resp.status_code}, {resp.text}") + + def __generate_sign(self, timestamp, section): + secret = self.cfg.get(section, 'secret') + + secret_enc = secret.encode('utf-8') + string_to_sign = '{}\n{}'.format(timestamp, secret) + string_to_sign_enc = string_to_sign.encode('utf-8') + hmac_code = hmac.new(secret_enc, string_to_sign_enc, digestmod=hashlib.sha256).digest() + sign = parse.quote_plus(base64.b64encode(hmac_code)) + + return sign diff --git a/src/framework/util/request_json_encoder.py b/src/framework/util/request_json_encoder.py new file mode 100644 index 0000000..7d83580 --- /dev/null +++ b/src/framework/util/request_json_encoder.py @@ -0,0 +1,47 @@ +# @Time : 2022-03-03 17:00:00 +# @Author : Gavin Jiang +# @Owner : YSRD (Insights Value) + +import json +from datetime import date, datetime +from decimal import Decimal +# import demjson +from sqlalchemy.ext.declarative import DeclarativeMeta + +from framework.vo.view_object import ViewObject + + +class RequestJsonEncoder(json.JSONEncoder): + def default(self, obj): + # sl加对model对象的转换 + if isinstance(obj.__class__, DeclarativeMeta): + + fields = {} + for field in [x for x in dir(obj) if + not x.startswith('_') and x != 'metadata' and x != 'query' and x != 'query_class']: + data = obj.__getattribute__(field) + try: + if data is None: + fields[field] = '' + elif isinstance(data, ViewObject): + # json.dumps(data,cls=ComplexEncoder) + fields[field] = json.dumps(data, ensure_ascii=False, cls=RequestJsonEncoder) + # fields[field] =json.dumps(data, default=lambda obj: obj.__dict__) + else: + fields[field] = data + + except TypeError: + fields[field] = data + return fields + # sl加对普通对象的转换 + # 因为demjson的安装经常会失败,默认关闭demjson功能,有需要的子系统可以自行开启 + # elif isinstance(obj, ViewObject): + # return demjson.decode(json.dumps(obj, ensure_ascii=False, default=lambda obj1: obj1.__dict__)) + elif isinstance(obj, datetime): + return obj.strftime('%Y-%m-%d %H:%M:%S') + elif isinstance(obj, date): + return obj.strftime('%Y-%m-%d') + elif isinstance(obj, Decimal): + return str(obj) + else: + return json.JSONEncoder.default(self, obj) diff --git a/src/framework/util/resp_util.py b/src/framework/util/resp_util.py new file mode 100644 index 0000000..16be448 --- /dev/null +++ b/src/framework/util/resp_util.py @@ -0,0 +1,82 @@ +# @Time : 2022-03-03 17:00:00 +# @Author : Gavin Jiang +# @Owner : YSRD (Insights Value) + +from framework.vo.resp_result import RespResult +from framework.constant.resp_code import RespCode + + +class RespUtil(object): + + @staticmethod + def ok(data=None) -> RespResult: + return RespResult(RespCode.OK, {} if data is None else data) + + @staticmethod + def inner_error(msg='') -> RespResult: + return RespResult(RespCode.INNER_ERROR, msg) + + @staticmethod + def please_try_later() -> RespResult: + return RespResult(RespCode.PLEASE_TRY_LATER, {}) + + @staticmethod + def cannot_find_microdev_type_code() -> RespResult: + return RespResult(RespCode.CANNOT_FIND_MICRODEV_TYPE_CODE, {}) + + @staticmethod + def microdev_not_in_work_order() -> RespResult: + return RespResult(RespCode.MICRODEV_NOT_IN_WORK_ORDER, {}) + + @staticmethod + def microdev_unauthorize_offline() -> RespResult: + return RespResult(RespCode.MICRODEV_UNAUTHORIZE_OFFLINE, {}) + + @staticmethod + def devsite_not_bind_microdev() -> RespResult: + return RespResult(RespCode.DEVSITE_NOT_BIND_MICRODEV, {}) + + @staticmethod + def post_repeat() -> RespResult: + return RespResult(RespCode.POST_REPEAT, {}) + + @staticmethod + def request_method_invalid() -> RespResult: + return RespResult(RespCode.REQUEST_METHOD_INVALID, {}) + + @staticmethod + def param_lack(msg='') -> RespResult: + return RespResult(RespCode.PARAM_LACK, msg) + + @staticmethod + def unauthorized() -> RespResult: + return RespResult(RespCode.UNAUTHORIZED, {}) + + @staticmethod + def not_found_user() -> RespResult: + return RespResult(RespCode.NOT_FOUND_USER, {}) + + @staticmethod + def invalid_param(msg='') -> RespResult: + return RespResult(RespCode.INVALID_PARAM, msg) + + @staticmethod + def data_error(msg='') -> RespResult: + return RespResult(RespCode.DATA_ERROR, msg) + + @staticmethod + def not_support(msg='') -> RespResult: + return RespResult(RespCode.NOT_SUPPORT, msg) + + @staticmethod + def not_found_data(msg='') -> RespResult: + return RespResult(RespCode.NOT_FOUND_DATA, msg) + + @staticmethod + def invalid_operation(msg='') -> RespResult: + return RespResult(RespCode.INVALID_OPERATION, msg) + + @staticmethod + def invalid_json_format(msg='') -> RespResult: + return RespResult(RespCode.INVALID_JSON_FORMAT, msg) + diff --git a/src/framework/util/sql_db.py b/src/framework/util/sql_db.py new file mode 100644 index 0000000..f102658 --- /dev/null +++ b/src/framework/util/sql_db.py @@ -0,0 +1,245 @@ +# @Time : 2022-03-03 17:00:00 +# @Author : Gavin Jiang +# @Owner : YSRD (Insights Value) + +import logging + +from flask import current_app +from sqlalchemy import text +from sqlalchemy.orm import sessionmaker + +import pandas as pd + +from framework.decorator.synchronized import synchronized +import socket + + +class SqlDb: + + # 获取中心地区的IP地址 用于识别中心 自动转中心数据库IP连接 + @staticmethod + def get_host_ip(): + try: + # 创建一个 socket 对象 + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + # 不需要建立连接,只是为了让操作系统产生一个网络流量,从而获取本机IP + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + finally: + s.close() + return ip + + @synchronized + def __new__(cls, *args, **kwargs): + if not hasattr(cls, '_instance'): + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + self._db = current_app.config['_SQLALCHEMY_INSTANCE'] + self._logger = logging.getLogger(__name__) + + def get_engine(self,bind=None): + ''' + bind设为None,返回的是默认的数据库 app.config['SQLALCHEMY_DATABASE_URI'],否则为app.config['SQLALCHEMY_BINDS']里, + ''' + return self._db.get_engine(app=current_app,bind="elec_db_2") + + def __good_sql(self, sql): + if isinstance(sql, str): + return text(sql) + return sql + + def __build_in_condition(self, param_prefix, values): + param_names = [] + params = {} + + index = 0 + for value in values: + param_name = f"{param_prefix}_{index}" + param_names.append(f":{param_name}") + params[param_name] = value + + index = index + 1 + + return ','.join(param_names), params + + def __execute(self, connection, sql, params): + if params and isinstance(params, dict): + for key, value in params.copy().items(): + if isinstance(sql, str) and (isinstance(value, list) or isinstance(value, tuple)): + stmt, stmt_params = self.__build_in_condition(key, value) + sql = sql.replace(f":{key}", f"({stmt})") + params.update(stmt_params) + + return connection.execute(self.__good_sql(sql), params) + + def list(self, sql, params=None, bind=None): + with self.get_engine(bind).begin() as connection: + result = self.__execute(connection, sql, params) + return [x._asdict() for x in result.all()] + + def list2(self, sql, params=None, bind=None): + with self.get_engine(bind).begin() as connection: + result = self.__execute(connection, sql, params) + data_list = [x._asdict() for x in result.all()] + + output = {} + keydict = {} + + # 处理所有可能的键值对 + for key_group in output.keys(): + if isinstance(output[key_group], dict): + for key in output[key_group]: + new_key = output[key_group][key] + new_value = output.get('maintance_value', {}).get(key, None) + keydict[new_key] = new_value + + # 将结果转换为不同key存放的list形式 + combined_dict = {} + for item in data_list: + for key, value in item.items(): + if key not in combined_dict: + combined_dict[key] = [] + combined_dict[key].append(value) + + return combined_dict + + def value(self, sql, params=None, bind=None): + with self.get_engine(bind).begin() as connection: + result = self.__execute(connection, sql, params) + return result.scalar() + + def orm_session(self): + my_engine = self.get_engine() + Session = sessionmaker(bind=my_engine) + db_session = Session() + return db_session + + def first(self, sql, params=None, bind=None): + with self.get_engine(bind).begin() as connection: + result = self.__execute(connection, sql, params) + data = result.first() + if data is None: + return None + return data._asdict() + + def __process_value(self, value): + if value is None: + return 'null' + + if isinstance(value, str): + value = value.replace("'", "''") + return f"'{value}'" + + return value + + def __build_sql_for_insert(self, table_name, col_names, col_values, replace): + col_str = ','.join([f"`{x}`" for x in col_names]) + + if not (col_names and col_values): + self._logger.debug(f"col_names({col_names})和col_values({col_values})不能为空,请检查!") + return False + + sql = f"{'replace' if replace else 'insert'} into {table_name} ({col_str}) values" + + if isinstance(col_values[0], tuple): + sql = sql + ','.join([f"({','.join(self.__process_value(x) for x in item)})" for item in col_values]) + else: + sql = sql + f"({','.join(self.__process_value(x) for x in col_values)})" + + return sql + + def insert(self, table_name, data, method='append', bind=None): + """ + 从原有的拼接sql修改为用pandas,可以方式sql注入 + Args: + table_name:表名 + data:需要插入的数据,list类型 + method: append,replace,fail + Returns: + 如果插入成功返回True,否则返回错误信息 + """ + if not isinstance(data, list): + self._logger.warning(f"data应为list时,略过此元素") + + try: + df = pd.DataFrame(data) + + print(df) + with self.get_engine(bind).begin() as connection: + df.to_sql(name=table_name, con=self.get_engine(bind), if_exists=method, index=False) + except Exception as e: + return e + + return True + + def update(self, table_name: str, data: dict, where: str, params=None, bind=None): + """ + 更新数据 + Args: + table_name:表名 + data:需要更新的数据,字典类型 + where:更新条件,严禁拼接参数构造条件字符串,使用占位符或者占位名 + params:参数 + + Returns: + 如果更新成功,返回True,否则返回False + """ + if not where: + self._logger.error("where必须非空,更新数据要传递条件") + return False + + sql = f"update {table_name} set " + sql = sql + ",".join([f"`{key}`={self.__process_value(value)}" for key,value in data.items()]) + sql = f"{sql} where {where}" + + self._logger.debug(f"update sql: {sql}") + + with self.get_engine(bind).begin() as connection: + if params is None: + params = {} + result = connection.execute(text(sql), **params) + + return result.rowcount > 0 + + def delete(self, table_name: str, where: str, params=None, bind=None): + if not where: + self._logger.error("where必须非空,删除数据要传递条件") + + sql = f"delete from {table_name} where {where}" + + self._logger.debug(f"delete sql: {sql}") + + with self.get_engine(bind).begin() as connection: + if params is None: + params = {} + result = connection.execute(text(sql), **params) + + return result.rowcount > 0 + + def query(self, sql, params=None, bind=None): + with self.get_engine(bind).begin() as connection: + self.__execute(connection, sql, params) + + def insert_else(self, table_name, data, bind=None): + if data: + columns = ','.join([f"`{key}`" for key in data.keys()]) + values = ','.join([f"{self.__process_value(value)}" for value in data.values()]) + sql = f"insert into {table_name}({columns}) values({values})" + + self._logger.debug(f"insert sql: {sql}") + + with self.get_engine(bind).begin() as connection: + result = connection.execute(text(sql)) + return result.lastrowid + + def bulk_insert(self, table_name, data, bind=None): + values = ', '.join([f"({', '.join(str(self.__process_value(value)) for value in row.values())})" for row in data]) + columns = ', '.join(data[0].keys()) + sql = f"REPLACE INTO {table_name}({columns}) VALUES {values}" + + self._logger.debug(f"bulk insert sql: {sql}") + with self.get_engine(bind).begin() as connection: + result = connection.execute(text(sql)) + return result.rowcount > 0 diff --git a/src/framework/vo/resp_result.py b/src/framework/vo/resp_result.py new file mode 100644 index 0000000..d05d7a4 --- /dev/null +++ b/src/framework/vo/resp_result.py @@ -0,0 +1,15 @@ +from framework.constant.resp_code import RespCode + + +class RespResult(object): + + def __init__(self, resp_code: RespCode, result: object): + self._resp_code = resp_code + self._result = result + + def build(self): + return { + 'code': self._resp_code.value, + 'msg': self._resp_code.desc(), + 'result': self._result + } diff --git a/src/framework/vo/view_object.py b/src/framework/vo/view_object.py new file mode 100644 index 0000000..2e6446b --- /dev/null +++ b/src/framework/vo/view_object.py @@ -0,0 +1,2 @@ +class ViewObject(object): + pass diff --git a/src/runserver.py b/src/runserver.py new file mode 100644 index 0000000..59ca2c5 --- /dev/null +++ b/src/runserver.py @@ -0,0 +1,90 @@ +# @Time : 2022-03-03 17:00:00 +# @Author : Gavin Jiang +# @Owner : YSRD (Insights Value) + +import logging.config +import os +import traceback + +import yaml +from flask import Flask, Response +from flask_cors import CORS +from flask_sqlalchemy import SQLAlchemy + +from framework.bootstrap.config_loader import ConfigLoader +from framework.bootstrap.register_router import RegisterRouter +from framework.interceptor.interceptor_loader import InterceptorLoader +from framework.the_path import ThePath + +def _init_interceptor(app: Flask): + """ + 注册拦截器 + @param app: + @return: + """ + before_list, after_list = InterceptorLoader.load_interceptors() + + @app.before_request + def before_request(): + for instance in before_list: + try: + instance.handle() + except Exception as e: + logging.error(f"执行before拦截器失败:\n{instance.__class__},捕获异常:\n{traceback.format_exc()}") + return str(e) + + @app.after_request + def after_request(resp: Response): + for instance in after_list: + try: + resp = instance.handle(resp) + except Exception: + logging.error(f"执行after拦截器失败:\n{instance.__class__},捕获异常:\n{traceback.format_exc()}") + + return resp + + +def _set_logging(log_config_path): + # 查找log文件夹,如果不存在则自动创建 + ThePath.tmp('log') + with open(log_config_path) as f: + config = yaml.load(f, Loader=yaml.FullLoader) + config['handlers']['timed_rotating_file']['filename'] = os.path.join(ThePath.tmp('log'), 'api.log') + logging.config.dictConfig(config) + + +def _init(env=None): + config_loader = ConfigLoader(env) + config_loader.load() + + _set_logging(config_loader.data.get('log')) + + app = Flask(__name__) + + app.config.from_pyfile(config_loader.data.get('flask')) + + _init_interceptor(app) + + RegisterRouter().register_router(app) + + CORS(app, supports_credentials=True) + sql_alchemy = SQLAlchemy(app) + + app.config['_SQLALCHEMY_INSTANCE'] = sql_alchemy + print('env:', config_loader.env) + return app + + +create_app = _init + + +def startup(env): + app = _init(env) + + print(app.url_map) + app.run(host=app.config['HOST'], port=app.config['PORT']) + + +if __name__ == '__main__': + startup('dev') + diff --git a/src/views/add_test_key_model.py b/src/views/add_test_key_model.py new file mode 100644 index 0000000..41bc78d --- /dev/null +++ b/src/views/add_test_key_model.py @@ -0,0 +1,12 @@ +from framework.interface.abstract_view_model import AbstractViewModel + +class Add_Test_Key_Model(AbstractViewModel): + """ + 向返回值中加一个名为test的key + """ + def process(self, raw_result): + if not isinstance(raw_result, dict): + raise Exception('返回值类型不为字典!') + + raw_result['test'] = '该返回值由Add_Test_Key_Model生成' + return raw_result diff --git a/src/wsgi.py b/src/wsgi.py new file mode 100644 index 0000000..b2bf2b0 --- /dev/null +++ b/src/wsgi.py @@ -0,0 +1,5 @@ +import os +from runserver import create_app + +# app = create_app('prod') if os.getenv('ENV') != 'pre' and os.getenv('ENV') != None else create_app('pre') +app = create_app(os.getenv('ENV')) # 目前有:正式环境 prod;预发环境 pre;测试环境 test diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..4cc2654 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,19 @@ +# @Time : 2022-03-03 17:00:00 +# @Author : Sun Wen Quan +# @Owner : YSRD (Insights Value) + +# content of conftest.py + +import pytest + +from framework.util.sql_db import SqlDb +from runserver import create_app + +@pytest.fixture +def client(): + app = create_app('test') + with app.test_client() as client: + with app.app_context(): + db = SqlDb() + + yield client diff --git a/test/demo/test_api_framework_api.py b/test/demo/test_api_framework_api.py new file mode 100644 index 0000000..a3baefb --- /dev/null +++ b/test/demo/test_api_framework_api.py @@ -0,0 +1,25 @@ +# @Time : 2022-03-31 11:00:00 +# @Author : Wang Fei hong +# @Owner : YSRD (Insights Value) +from runserver import create_app + + +class TestApiFrameWorkApi: + def test_hander_get(self, client): + response = client.get('/demo/api-frame-work-api?json={"id":"1","name":"test"}') + assert response.json['code'] == 2000 + assert response.json['result']['params'] == {"id":"1","name":"test"} + + def test_hander_post(self, client): + response = client.post('/demo/api-frame-work-api') + assert response.status_code == 200 + assert response.json['code'] == 2000 + assert response.json['msg'] == '访问成功' + + def test_missing_required_parameter(self, client): + response = client.get('/demo/api-frame-work-api') + assert response.json['code'] == 3009 + + def test_invalid_parameter(self, client): + response = client.get('/demo/api-frame-work-api', data={'project_name': "hello"}) + assert response.json['code'] == 3009 \ No newline at end of file diff --git a/test/demo/test_hello_sqldb.py b/test/demo/test_hello_sqldb.py new file mode 100644 index 0000000..02005dc --- /dev/null +++ b/test/demo/test_hello_sqldb.py @@ -0,0 +1,31 @@ +# @Time : 2022-03-03 17:00:00 +# @Author : Sun Wen Quan +# @Owner : YSRD (Insights Value) + +import requests +import datetime + + +class TestHelloSqldb: + def test_hello_db(self, client): + response = client.get('/demo/hello-sqldb') + assert response.json['code'] == 2000 + + def test_hello_db_insert(self, client): + data = {"id": 123, "name": "test1", "created_at": ["testtttt", "2022-03-23 17:57:23"], + "updated_at": "2022-03-23 17:57:23", "op": "add"} + response = client.post('/demo/hello-sqldb', data=data) + assert response.json['code'] == 2000 + assert response.json['result']['result'] == True + + def test_hello_db_update(self, client): + data = {"id": 123, "name": "t1", "op": "update"} + response = client.post('/demo/hello-sqldb', data=data) + assert response.json['code'] == 2000 + assert response.json['result']['result'] == True + + def test_hello_db_delete(self, client): + data = {"id": 123, "op": "delete"} + response = client.post('/demo/hello-sqldb', data=data) + assert response.json['code'] == 2000 + assert response.json['result']['result'] == True \ No newline at end of file diff --git a/test/demo/test_hello_world.py b/test/demo/test_hello_world.py new file mode 100644 index 0000000..83a2739 --- /dev/null +++ b/test/demo/test_hello_world.py @@ -0,0 +1,23 @@ +# @Time : 2022-03-03 17:00:00 +# @Author : Sun Wen Quan +# @Owner : YSRD (Insights Value) + +class TestHelloWorld: + def test_hander_get(self, client): + response = client.get('/demo/hello-world?user_id=1&user_name=&user_email=87909@qq.com&user_age=1&user_age=1') + assert response.json['code'] == 2000 + assert response.json['result']['params'].get('user_id') == 1 + + def test_hander_post(self, client): + response = client.post('/demo/hello-world') + assert response.status_code == 200 + assert response.json['code'] == 2000 + assert response.json['msg'] == '访问成功' + + def test_missing_required_parameter(self, client): + response = client.get('/demo/hello-world') + assert response.json['code'] == 3009 + + def test_invalid_parameter(self, client): + response = client.get('/demo/hello-world', data={'project_name': "hello"}) + assert response.json['code'] == 3009 \ No newline at end of file diff --git a/test/demo/test_upload_tmp_file.py b/test/demo/test_upload_tmp_file.py new file mode 100644 index 0000000..5304101 --- /dev/null +++ b/test/demo/test_upload_tmp_file.py @@ -0,0 +1,35 @@ +# @Time : 2022-03-03 17:00:00 +# @Author : Sun Wen Quan +# @Owner : YSRD (Insights Value) + +import os + +from framework.the_path import ThePath + + +class TestUploadTmpFile: + + def test_upload_tmp_file(self, client): + filename = os.path.join(ThePath.test(), 'data/test_upload_file.txt') + data = {'file': open(filename, 'rb')} + response = client.post('/demo/upload-tmp-file', data=data) + assert response.json.get('code') == 2000 + tmp_file_path = response.json.get('result')['file_result'][0]['tmp_file_path'] + assert os.path.isfile(tmp_file_path) + + def test_upload_tmp_file_clean(self, client): + filename = os.path.join(ThePath.test(), 'data/test_upload_file.txt') + print(filename) + data = {'file': open(filename, 'rb')} + response = client.post('/demo/upload-tmp-file-clean', data=data) + print(response.data) + assert response.json.get('code') == 2000 + try: + tmp_file_path = response.json.get('result')['file_result'][0]['tmp_file_path'] + assert not os.path.isfile(tmp_file_path), '文件没有被自动删除' + except IndexError: + assert False, '上传文件失败!' + except KeyError: + assert False, '响应数据数据不规范!' + else: + assert 'tmp/post_files' in tmp_file_path, '响应信息中tmp_file_path的格式有误!' diff --git a/test/framework/bootstrap/test_config_loader.py b/test/framework/bootstrap/test_config_loader.py new file mode 100644 index 0000000..146c759 --- /dev/null +++ b/test/framework/bootstrap/test_config_loader.py @@ -0,0 +1,35 @@ +# @Time : 2022-03-03 17:00:00 +# @Author : Gavin Jiang +# @Owner : YSRD (Insights Value) + +from framework.bootstrap.config_loader import ConfigLoader + + +class TestConfigLoader: + + @staticmethod + def log_config_verification(env): + config_loader = ConfigLoader(env) + config_loader.load() + return f'config/default/log.yml' in config_loader.data['log'].replace('\\', '/') + + @staticmethod + def flask_config_verification(env): + config_loader = ConfigLoader(env) + config_loader.load() + return f'api/{env}/flask.py' in config_loader.data['flask'].replace('\\', '/') + + def test_config_by_args(self): + assert self.flask_config_verification('prod') + assert self.flask_config_verification('test') + assert self.flask_config_verification('dev') + + assert self.log_config_verification('prod') + assert self.log_config_verification('test') + assert self.log_config_verification('dev') + + def test_config_by_env(self): + """保证部署的时候使用的默认环境是生产环境""" + config_loader = ConfigLoader() + config_loader.load() + assert f'api/prod/flask.py' in config_loader.data['flask'].replace('\\', '/') diff --git a/test/framework/constant/test_resp_code.py b/test/framework/constant/test_resp_code.py new file mode 100644 index 0000000..3eaa496 --- /dev/null +++ b/test/framework/constant/test_resp_code.py @@ -0,0 +1,14 @@ +# @Time : 2022-03-03 17:00:00 +# @Author : Gavin Jiang +# @Owner : YSRD (Insights Value) + +from framework.constant.resp_code import RespCode + + +class TestRespCode: + + def test1(self): + assert RespCode.OK.value == 2000 + assert RespCode.OK.desc() == '访问成功' + + assert RespCode.INVALID_JSON_FORMAT.desc() == '错误的JSON格式' diff --git a/test/framework/interceptor/test_interceptor_loader.py b/test/framework/interceptor/test_interceptor_loader.py new file mode 100644 index 0000000..5b581cd --- /dev/null +++ b/test/framework/interceptor/test_interceptor_loader.py @@ -0,0 +1,17 @@ +# @Time : 2022-03-03 17:00:00 +# @Author : Gavin Jiang +# @Owner : YSRD (Insights Value) + +from framework.interceptor.clear_interceptor import ClearInterceptor +from framework.interceptor.interceptor_loader import InterceptorLoader +from framework.interceptor.param_interceptor import ParamInterceptor + + +class TestInterceptorLoader: + + def test_load_interceptors(self): + before_list, after_list = InterceptorLoader.load_interceptors() + before_class_list = (obj.__class__ for obj in before_list) + after_class_list = (obj.__class__ for obj in after_list) + assert ParamInterceptor in before_class_list + assert ClearInterceptor in after_class_list diff --git a/test/framework/test_the_path.py b/test/framework/test_the_path.py new file mode 100644 index 0000000..8d627c3 --- /dev/null +++ b/test/framework/test_the_path.py @@ -0,0 +1,33 @@ +# @Time : 2022-03-03 17:00:00 +# @Author : Gavin Jiang +# @Owner : YSRD (Insights Value) + +import os + +import pytest + +from framework.the_path import ThePath + + +@pytest.fixture(scope='module') +def project_session_start(): + base_path = os.path.dirname(os.path.abspath(__file__)) + print('base_path is :', base_path) + + +class TestThePath: + + def test_root(self): + root_path = ThePath.root() + children = os.listdir(root_path) + assert {'bin', 'config', 'src', 'test'}.issubset(children) + + def test_src(self): + src_path = ThePath.src() + children = os.listdir(src_path) + assert {'app', 'component', 'framework'}.issubset(children) + + def test_test(self): + test_path = ThePath.test() + children = os.listdir(test_path) + assert {'data', 'conftest.py', 'test_create_app.py','__init__.py'}.issubset(children) diff --git a/test/test_create_app.py b/test/test_create_app.py new file mode 100644 index 0000000..fac39fd --- /dev/null +++ b/test/test_create_app.py @@ -0,0 +1,12 @@ +# @Time : 2022-03-03 17:00:00 +# @Author : Sun Wen Quan +# @Owner : YSRD (Insights Value) + +def test_no_error(): + try: + from runserver import create_app + app = create_app + msg = 'ok' + except ValueError as e: + msg = e + assert msg == 'ok' -- libgit2 0.26.0