├── .github ├── ISSUE_TEMPLATE │ ├── ----.md │ ├── bug.md │ ├── help.md │ └── improvement.md └── workflows │ └── main.yml ├── .gitignore ├── .pylintrc ├── LICENSE ├── README.md ├── config.py ├── demo └── hello │ ├── README.md │ ├── config.py │ ├── http.jpg │ ├── run.py │ └── weixin.jpg ├── docs ├── accesspoint.py ├── accesspoints.json ├── accesspoints.md ├── api │ ├── api.commodity.html │ ├── api.commodity.md │ ├── api.rbac.html │ └── api.rbac.md ├── deployment.md ├── errcode.json ├── errcode.md ├── errcode.py ├── illustration │ ├── JWT验证.drawio │ ├── JWT验证.png │ ├── 事件管理器.drawio │ ├── 事件管理器.png │ ├── 支付流程.drawio │ └── 支付流程.png ├── introduction.md ├── logo-bar.png ├── other.md └── public │ ├── logo-bar.drawio │ ├── logo-bar.png │ ├── logo-bar.svg │ ├── logo-cropped.svg │ ├── logo.old.drawio │ ├── logo.png │ ├── logo.svg │ ├── social-page.png │ └── structure.drawio ├── leaf ├── __init__.py ├── api │ ├── __init__.py │ ├── error.py │ ├── settings.py │ ├── validator.py │ └── wrapper.py ├── core │ ├── __init__.py │ ├── abstract │ │ ├── __init__.py │ │ ├── payment.py │ │ └── plugin.py │ ├── algorithm │ │ ├── __init__.py │ │ ├── concdict.py │ │ ├── fsm.py │ │ ├── keydict.py │ │ └── tree.py │ ├── database.py │ ├── error.py │ ├── events.py │ ├── parallel.py │ ├── schedule.py │ ├── tools │ │ ├── __init__.py │ │ ├── encrypt.py │ │ ├── file.py │ │ ├── time.py │ │ └── web.py │ └── wrapper.py ├── files │ ├── static │ │ └── README.md │ └── uploads │ │ └── README.md ├── payments │ ├── __init__.py │ ├── alipay │ │ └── __init__.py │ ├── wxpay │ │ ├── __init__.py │ │ ├── const.py │ │ ├── error.json │ │ ├── error.py │ │ ├── methods.py │ │ ├── payment.py │ │ ├── settings.py │ │ └── signature.py │ └── yandex │ │ └── __init__.py ├── plugins │ ├── __init__.py │ ├── accesstoken │ │ └── __init__.py │ ├── error.py │ ├── manager.py │ └── settings.py ├── rbac │ ├── __init__.py │ ├── error.py │ ├── functions │ │ ├── __init__.py │ │ ├── accesspoint.py │ │ ├── auth.py │ │ ├── group.py │ │ └── user.py │ ├── jwt │ │ ├── __init__.py │ │ ├── const.py │ │ ├── settings.py │ │ ├── token.py │ │ └── verify.py │ ├── model │ │ ├── __init__.py │ │ ├── accesspoint.py │ │ ├── auth.py │ │ ├── group.py │ │ └── user.py │ └── settings.py ├── selling │ ├── __init__.py │ ├── commodity │ │ ├── __init__.py │ │ ├── generator.py │ │ ├── product.py │ │ └── stock.py │ ├── error.py │ ├── functions │ │ ├── __init__.py │ │ ├── product.py │ │ └── stock.py │ ├── order │ │ ├── __init__.py │ │ ├── events.py │ │ ├── manager.py │ │ ├── order.py │ │ ├── settings.py │ │ └── status.py │ └── settings.py ├── views │ ├── __init__.py │ ├── commodity │ │ ├── __init__.py │ │ ├── product.py │ │ └── stock.py │ ├── order.py │ ├── plugins.py │ ├── rbac │ │ ├── __init__.py │ │ ├── accesspoint.py │ │ ├── auth.py │ │ ├── group.py │ │ ├── jwt.py │ │ └── user.py │ ├── weixin.py │ └── wxpay.py └── weixin │ ├── __init__.py │ ├── accesstoken │ ├── __init__.py │ ├── const.py │ ├── error.py │ ├── events.py │ ├── patch.py │ └── settings.py │ ├── apis │ ├── __init__.py │ ├── template.py │ └── user.py │ ├── const.py │ ├── encrypt.py │ ├── errcodes.json │ ├── error.py │ ├── reply │ ├── __init__.py │ ├── event.py │ ├── maker.py │ ├── message.py │ └── url.py │ └── settings.py ├── linting.py ├── requirements.txt ├── run.py └── setup.py /.github/ISSUE_TEMPLATE/----.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 功能建议 3 | about: 为本项目提供您的意见 4 | title: "[Improvement]" 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **您的意见是否由一个错误导致** 11 | 如果是,您可以考虑提交 bug 类型的 issue 12 | 13 | **描述您希望的改进** 14 | 在此详细的描述您希望的改进,他们可能是: 15 | - 功能增加 16 | - 现有功能的改进 17 | - 其他 18 | 19 | **实现方案/思路** 20 | 如果您有实现方案/思路,请在此提出 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug 报告 3 | about: 告诉我们使用过程中发现的bug 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Bug 描述** 11 | 请简要的描述您遇到的问题,包含: 12 | 1. 您预期的行为 13 | 2. 实际发生的行为 14 | 15 | **是否可以复现** 16 | 是/否 17 | 18 | **如何复现** 19 | 如果可以复现,请简要描述复现过程 20 | 21 | **环境信息** 22 | - OS 23 | - Python版本 24 | - Version 25 | - 使用依赖库版本 26 | 27 | **额外信息** 28 | 如果有日志,请给出相关部分 29 | 您如果有对此bug的修复建议,可以在此提出 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/help.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 需要帮助 3 | about: 如果您需要使用帮助请在此提出 4 | title: "[Help]" 5 | labels: help wanted 6 | assignees: '' 7 | 8 | --- 9 | 10 | **问题描述** 11 | 简要的描述需要帮助解决的问题/疑问 12 | 13 | **环境信息** 14 | *可选的* 留下您所使用的环境/版本信息以便我们更好地帮助您 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/improvement.md: -------------------------------------------------------------------------------- 1 | --- --- 2 | name: 功能建议 3 | about: 为本项目提供您的意见 4 | title: "[Improvement]" 5 | labels: 'enhancement' 6 | assignees: '' 7 | 8 | 9 | --- --- 10 | 11 | 12 | **您的意见是否由一个错误导致** 13 | 如果是,您可以考虑提交 bug 类型的 issue 14 | 15 | 16 | **描述您希望的改进** 17 | 在此详细的描述您希望的改进,他们可能是: 18 | - 功能增加 19 | - 现有功能的改进 20 | - 其他 21 | 22 | **实现方案/思路** 23 | 如果您有实现方案/思路,请在此提出 -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Test and Pylint 2 | 3 | on: [push] 4 | 5 | jobs: 6 | linting: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | id: checkout 14 | 15 | - name: Setup Python 16 | uses: actions/setup-python@v1 17 | with: 18 | python-version: 3.7 19 | 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install -r requirements.txt 24 | pip install pylint 25 | 26 | - name: Test modules 27 | run: python run.py 28 | id: test 29 | 30 | - name: Pylinting 31 | if: success() 32 | run: python linting.py 33 | id: linting 34 | 35 | - name: Low score warning 36 | if: steps.linting.outputs.score < 9 37 | run: echo "Test passed, but score was too low." 38 | 39 | - name: Passed 40 | if: steps.linting.outputs.score >= 9 41 | run: echo "All passed." 42 | 43 | - name: Show score 44 | run: echo "Your score is ${{ steps.linting.outputs.score }}." 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # Leaf multiprocessing control 132 | .leaf.lock 133 | .*.lock 134 | 135 | # Visual Studio Code config files 136 | .vscode/ 137 | 138 | # MacOS Annoying file 139 | .DS_Store 140 | 141 | # Local test env 142 | .test/ 143 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | """Leaf 框架配置示例文件""" 2 | 3 | # 以下是系统常量, 请勿进行修改 4 | 5 | ############################################################### 6 | 7 | # 静态字典类 8 | 9 | 10 | class Static(dict): 11 | """静态 Dict 类""" 12 | 13 | def __getattr__(self, key): 14 | """返回本地键值对""" 15 | return self[key] 16 | 17 | def __setattr__(self, key, value): 18 | """删除设置功能""" 19 | raise RuntimeError("StaticDict cannot be set value") 20 | 21 | def __delattr__(self, key): 22 | """删除删除功能""" 23 | raise RuntimeError("StaticDict cannot be remove value") 24 | 25 | 26 | # 日志等级常量定义 27 | CRITICAL, FATAL, ERROR = 50, 50, 40 28 | WARNING, INFO, DEBUG, NOTSET = 30, 20, 10, 0 29 | 30 | ############################################################### 31 | 32 | # 配置文件开始, 您可以开始您的配置 33 | 34 | # 基础相关配置 35 | # domain - 您部署 Leaf 的域名 36 | # locker - 进程锁文件名 - 一般无需更改 37 | # manager - 管理器连接地址 - 一般无需更改 38 | # autkey - 管理器验证密钥 - 请更换成您喜欢的英文单词 39 | basic = Static({ 40 | "domain": "wxleaf.dev", 41 | "locker": ".leaf.lock", 42 | "manager": None, 43 | "authkey": "password" 44 | }) 45 | 46 | 47 | # 开发相关配置 48 | # 在生产环境请务必禁用开发者模式! 49 | # enable: 启用开发者模式 - 仅当为 True 时以下选项生效 50 | # 该选项同时影响 Flask 的默认 Debug 启动参数 51 | # token: 开发用 JWT Token - 无需验证身份 52 | devlopment = Static({ 53 | "enable": False, 54 | "token": "here is a secret token" 55 | }) 56 | 57 | 58 | # 错误与日志处理配置 59 | logging = Static({ 60 | 61 | # 默认的日志等级; 一般不需要修改 62 | "level": INFO, 63 | 64 | # 日志的存储位置及名称 65 | # 请确保程序有该处的读写权限 66 | "rcfile": "leaf.log", 67 | 68 | # 日志格式 - None 表示使用默认; 更多配置请参考 69 | # https://docs.python.org/3/library/logging.html 70 | "format": None, 71 | 72 | # 控制台日志输出配置 73 | # level - 单独设置控制台的输出等级 74 | # format - 单独设置控制台的输出格式; None 表示继承上面的格式 75 | "console": Static({ 76 | "level": DEBUG, 77 | "format": None # 表示使用父级 logger 配置 78 | }), 79 | 80 | # 文件日志输出配置 81 | # level - 单独设置文件的输出等级 82 | # format - 单独设置文件的输出格式; None 表示继承上面的格式 83 | "file": Static({ 84 | "level": ERROR, 85 | "format": None # 表示使用父级 logger 配置 86 | }) 87 | }) 88 | 89 | 90 | # 微信公众平台配置 91 | # appid - 微信公众平台 AppId 92 | # aeskey - 微信公众平台 AESKey 93 | # token - 微信公众平台消息传递 Token 94 | # secret - 微信公众平台 AppSecret 95 | # accesstoken.enbale - 是否启用 ACToken 自动更新功能 96 | # accesstoken.retries - 遇到错误之后的重试次数 97 | weixin = Static({ 98 | "appid": "wxabcd1234abcd1234", 99 | "aeskey": "s5d6t7vybotcre3465d68f7ybvtd4sd5687g8huhyvt", 100 | "token": "s547d6figobunb67568d8f7g8ohjiks1", 101 | "secret": "f5a3462707c2a31e51ff1b04efd1ed39", 102 | "accesstoken": Static({ 103 | "enable": True, 104 | "retries": 5 105 | }) 106 | }) 107 | 108 | 109 | # 微信支付配置 110 | # appid - 微信支付关联的公众号 AppId 111 | # mchid - 微信支付商户 Id 112 | # apikey - 微信支付 API 密钥 113 | wxpay = Static({ 114 | "appid": "wxabcd1234abcd1234", 115 | "mchid": "8888888888", 116 | "apikey": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 117 | 118 | # 回调地址 119 | # pay - 表示支付成功的回调地址 120 | # refund - 表示退款成功的回调地址 121 | # 除非您有其余服务部署在以下地址, 否则请不要修改 122 | "callbacks": { 123 | "pay": "https://" + basic.domain + "/wxpay/notify", 124 | "refund": "https://" + basic.domain + "/wxpay/notify_refund" 125 | }, 126 | 127 | # 退款操作证书位置 128 | # 第一个为签名文件路径, 第二个为证书文件路径 129 | # 请填写绝对路径 130 | # 如果您确定自己的不需要使用退款功能, 可以随意填写 131 | "cert": ( 132 | "root\\key.pem", 133 | "root\\cert.pem" 134 | ) 135 | }) 136 | 137 | 138 | # 数据库配置 139 | # database - 需要连接的数据库 140 | # host - 数据库服务器地址 141 | # port - 数据库服务端口 142 | # username - 用户名; None 表示不需要 143 | # password - 密码; None 表示不需要 144 | # timeout - 连接超时时间; 以秒为单位 145 | database = Static({ 146 | "database": "test", 147 | "host": "localhost", 148 | "port": 27017, 149 | "username": None, 150 | "password": None, 151 | "timeout": 5 152 | }) 153 | 154 | 155 | # 插件配置 156 | # autorun - Leaf 启动时插件是否自动运行 157 | # directory - 插件加载位置; None 表示不需要 158 | plugins = Static({ 159 | "autorun": False, 160 | "directory": None 161 | }) 162 | -------------------------------------------------------------------------------- /demo/hello/README.md: -------------------------------------------------------------------------------- 1 | 这将是您入门 `Leaf` 使用的第一步☝️,只需要简单的环境准备与配置,您就可以在自己的微信公众号中看到一个来自 `Leaf` 的问候,让我们开始吧😄! 2 | 3 | ## 环境准备 4 | 5 | 在运行这个 `Demo` 之前,您需要按照如下步骤准备环境: 6 | 7 | 1. 安装最新版本的 `Leaf`: 8 | 9 | ```shell 10 | pip install wxleaf 11 | ``` 12 | 13 | 版本应在 `1.0.9.1.dev8` 及以上 14 | 15 | 2. 确保您当前用户有权限使用服务器的80端口 16 | 17 | 3. 在微信公众平台获取您微信公众账号的如下开发配置参数: 18 | 19 | - AppID 20 | - AppSecret 21 | - Token - 一般是您自己设置 22 | - AESEncryptKey - 如果您选择加密/混合模式发送消息 23 | 24 | ## 配置文件 25 | 26 | 之后在 `config.py` 的 第 98 - 101 行分别填入您刚刚获取到的开发参数: 27 | 28 | ```python 29 | weixin = Static({ 30 | "appid": "wxabcd1234abcd1234", # AppID 31 | "aeskey": "s5d6t7vybotcre3465d68f7ybvtd4sd5687g8huhyvt", # AESEncryptKey 32 | "token": "s547d6figobunb67568d8f7g8ohjiks1", # Token 33 | "secret": "f5a3462707c2a31e51ff1b04efd1ed39", # AppSecret 34 | ... 35 | }) 36 | ``` 37 | 38 | ## 开始 39 | 40 | 运行我们的服务器: 41 | 42 | ```shell 43 | python3 run.py 44 | ``` 45 | 46 | 所有配置都已经完成,在微信公众平台接口配置信息的 `URL` 部分填入您服务器的域名+ `/weixin/callback` 之后提交; 47 | 48 | 现在,在您的公众号发送一条消息,即可看到来自 `Leaf` 的问候!![weixin](https://github.com/guiqiqi/leaf/blob/dev/demo/hello/weixin.jpg?raw=true) 49 | 50 | 直接访问您的域名也可以看到它: 51 | 52 | ![http](https://github.com/guiqiqi/leaf/blob/dev/demo/hello/http.jpg?raw=true) 53 | 54 | Enjoy using! 😉 55 | 56 | -------------------------------------------------------------------------------- /demo/hello/config.py: -------------------------------------------------------------------------------- 1 | """Leaf 框架配置示例文件""" 2 | 3 | # 以下是系统常量, 请勿进行修改 4 | 5 | ############################################################### 6 | 7 | # 静态字典类 8 | 9 | 10 | class Static(dict): 11 | """静态 Dict 类""" 12 | 13 | def __getattr__(self, key): 14 | """返回本地键值对""" 15 | return self[key] 16 | 17 | def __setattr__(self, key, value): 18 | """删除设置功能""" 19 | raise RuntimeError("StaticDict cannot be set value") 20 | 21 | def __delattr__(self, key): 22 | """删除删除功能""" 23 | raise RuntimeError("StaticDict cannot be remove value") 24 | 25 | 26 | # 日志等级常量定义 27 | CRITICAL, FATAL, ERROR = 50, 50, 40 28 | WARNING, INFO, DEBUG, NOTSET = 30, 20, 10, 0 29 | 30 | ############################################################### 31 | 32 | # 配置文件开始, 您可以开始您的配置 33 | 34 | # 基础相关配置 35 | # domain - 您部署 Leaf 的域名 36 | # locker - 进程锁文件名 - 一般无需更改 37 | # manager - 管理器连接地址 - 一般无需更改 38 | # autkey - 管理器验证密钥 - 请更换成您喜欢的英文单词 39 | basic = Static({ 40 | "domain": "wxleaf.dev", 41 | "locker": ".leaf.lock", 42 | "manager": None, 43 | "authkey": "password" 44 | }) 45 | 46 | 47 | # 开发相关配置 48 | # 在生产环境请务必禁用开发者模式! 49 | # enable: 启用开发者模式 - 仅当为 True 时以下选项生效 50 | # 该选项同时影响 Flask 的默认 Debug 启动参数 51 | # token: 开发用 JWT Token - 无需验证身份 52 | devlopment = Static({ 53 | "enable": False, 54 | "token": "here is a secret token" 55 | }) 56 | 57 | 58 | # 错误与日志处理配置 59 | logging = Static({ 60 | 61 | # 默认的日志等级; 一般不需要修改 62 | "level": INFO, 63 | 64 | # 日志的存储位置及名称 65 | # 请确保程序有该处的读写权限 66 | "rcfile": "leaf.log", 67 | 68 | # 日志格式 - None 表示使用默认; 更多配置请参考 69 | # https://docs.python.org/3/library/logging.html 70 | "format": None, 71 | 72 | # 控制台日志输出配置 73 | # level - 单独设置控制台的输出等级 74 | # format - 单独设置控制台的输出格式; None 表示继承上面的格式 75 | "console": Static({ 76 | "level": DEBUG, 77 | "format": None # 表示使用父级 logger 配置 78 | }), 79 | 80 | # 文件日志输出配置 81 | # level - 单独设置文件的输出等级 82 | # format - 单独设置文件的输出格式; None 表示继承上面的格式 83 | "file": Static({ 84 | "level": ERROR, 85 | "format": None # 表示使用父级 logger 配置 86 | }) 87 | }) 88 | 89 | 90 | # 微信公众平台配置 91 | # appid - 微信公众平台 AppId 92 | # aeskey - 微信公众平台 AESKey 93 | # token - 微信公众平台消息传递 Token 94 | # secret - 微信公众平台 AppSecret 95 | # accesstoken.enbale - 是否启用 ACToken 自动更新功能 96 | # accesstoken.retries - 遇到错误之后的重试次数 97 | weixin = Static({ 98 | "appid": "wxabcd1234abcd1234", 99 | "aeskey": "s5d6t7vybotcre3465d68f7ybvtd4sd5687g8huhyvt", 100 | "token": "s547d6figobunb67568d8f7g8ohjiks1", 101 | "secret": "f5a3462707c2a31e51ff1b04efd1ed39", 102 | "accesstoken": Static({ 103 | "enable": True, 104 | "retries": 5 105 | }) 106 | }) 107 | 108 | # 插件配置 109 | # autorun - Leaf 启动时插件是否自动运行 110 | # directory - 插件加载位置; None 表示不需要 111 | plugins = Static({ 112 | "autorun": True, 113 | "directory": None 114 | }) 115 | -------------------------------------------------------------------------------- /demo/hello/http.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guiqiqi/leaf/79e34f4b8fba8c6fd208b5a3049103dca2064ab5/demo/hello/http.jpg -------------------------------------------------------------------------------- /demo/hello/run.py: -------------------------------------------------------------------------------- 1 | """Hello Leaf""" 2 | 3 | import logging 4 | import leaf 5 | import flask 6 | import config 7 | 8 | # 初始化核心模块 9 | init = leaf.Init() 10 | init.kernel(config.basic) 11 | init.logging(config.logging) 12 | init.server(config.devlopment) 13 | # init.database(config.database) 14 | 15 | # 初始化插件与微信模块 16 | init.weixin(config.weixin) # 微信公众平台支持模块 17 | init.plugins(config.plugins) # 插件模块 18 | 19 | # 获取模块实例 20 | logger = logging.getLogger("leaf.demo.hello") 21 | server: flask.Flask = leaf.modules.server # 服务器模块 22 | message: leaf.weixin.reply.Message = leaf.modules.weixin.message # 微信公众平台消息处理模块实例 23 | 24 | # 处理文本类型的消息 25 | @message.register("text") 26 | def reply_hello(**kwargs): 27 | """返回 Hello Leaf""" 28 | content = kwargs.get("content") 29 | logger.debug("MessageReceived: " + content) 30 | return leaf.weixin.reply.Maker.text("Hello Leaf!") 31 | 32 | # 在主页也返回一个 Hello Leaf 33 | @server.route("/") 34 | def show_hello(): 35 | """返回 Hello Leaf""" 36 | return "

Hello Leaf!

" 37 | 38 | server.run(host="0.0.0.0", port=80) 39 | -------------------------------------------------------------------------------- /demo/hello/weixin.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guiqiqi/leaf/79e34f4b8fba8c6fd208b5a3049103dca2064ab5/demo/hello/weixin.jpg -------------------------------------------------------------------------------- /docs/accesspoint.py: -------------------------------------------------------------------------------- 1 | """ 2 | 接入点代码管理器: 3 | 管理现有的接入点 4 | 根据视图函数生成接入点信息 5 | """ 6 | 7 | # 将 leaf 加入临时目录并引用 8 | import json 9 | import os as _os 10 | from typing import Optional 11 | from typing import Callable 12 | from typing import NoReturn 13 | from typing import List, Tuple 14 | from functools import namedtuple 15 | from collections import defaultdict 16 | import argparse as _argparse 17 | 18 | workdir: str = _os.path.dirname(_os.path.realpath(__file__)) 19 | __import__("sys").path.append(_os.path.dirname(workdir)) 20 | 21 | # pylint: disable=wrong-import-position 22 | import leaf as _leaf 23 | 24 | AccessRoute = namedtuple("AccessRoute", ("path", "methods")) 25 | AccessPoint = namedtuple("AccessPoint", ("name", "description")) 26 | 27 | Static = _leaf.core.algorithm.StaticDict 28 | CRITICAL, FATAL, ERROR = 50, 50, 40 29 | WARNING, INFO, DEBUG, NOTSET = 30, 20, 10, 0 30 | 31 | # 生成临时使用核心模块配置 32 | basic = Static({ 33 | "domain": "accesspoint.manage", 34 | "locker": ".ap-testconfig-leaf.lock", 35 | "manager": None, 36 | "authkey": "password" 37 | }) 38 | 39 | logging = Static({ 40 | "level": INFO, 41 | "rcfile": ".hidden.leaf.log", 42 | "format": None, 43 | "console": Static({ 44 | "level": DEBUG, 45 | "format": None 46 | }), 47 | "file": Static({ 48 | "level": ERROR, 49 | "format": None 50 | }) 51 | }) 52 | 53 | # 生成 leaf 应用实例 54 | init = _leaf.Init() 55 | init.kernel(basic) 56 | init.logging(logging) 57 | application = init.server() 58 | 59 | # 获取所有的视图函数与接入点映射 60 | _functions = application.view_functions 61 | _paths = {ap.endpoint: AccessRoute(ap.rule, tuple(ap.methods)) for 62 | ap in application.url_map.iter_rules()} 63 | urlmap = {_paths[key]: _functions[key] for key in _functions.keys()} 64 | accesspoints = list() 65 | 66 | # markdown 表头 67 | __HEADER = \ 68 | """ 69 | 以下是 _Leaf_ 现有的所有接入点信息及其介绍: 70 | 71 | | 接入点代码 | 接入点描述 | 接入点路径 | 允许的请求方式 | 72 | | :------ | | :---- | | :----- | | :----------- | 73 | """ 74 | 75 | 76 | # 命令行参数接收 77 | __parser = _argparse.ArgumentParser(description="接入点信息导出") 78 | __parser.add_argument("--type", "-t", default="markdown", 79 | help="type 表示导出文件类型, 支持 json, markdown. 默认为: markdown") 80 | __parser.add_argument("--export", "-e", help="export 参数表示导出的文件名", required=True) 81 | 82 | 83 | def __makeline(info: Tuple[str]) -> str: 84 | """制作新的一行""" 85 | return "| " + " | ".join(info) + " |" 86 | 87 | 88 | def __markdown(infos: List[Tuple[str]]) -> str: 89 | """制作 markdown 格式的数据""" 90 | lines = list() 91 | for info in infos: 92 | lines.append(__makeline(info)) 93 | return __HEADER + '\n'.join(lines) 94 | 95 | 96 | def __json(infos: List[Tuple[str]]) -> str: 97 | """制作 JSON 格式的数据""" 98 | lines = defaultdict(list) 99 | for info in infos: 100 | lines[info[0]].append(info[1:4]) 101 | return json.dumps(lines, indent=4, sort_keys=True, ensure_ascii=False) 102 | 103 | 104 | def unclosure(func: Callable, env: Optional[List[Tuple]] = None) -> \ 105 | Tuple[Callable, List[Tuple]]: 106 | """ 107 | 从闭包函数中迭代的找出最底层包装的函数 108 | 并逐层的统计闭包环境信息以列表返回 109 | """ 110 | if env is None: 111 | env = list() 112 | 113 | # 如果是不是包底函数 114 | if "__closure__" in dir(func) and func.__closure__: 115 | 116 | # 被闭包的下一层函数 117 | next_stage: Callable = None 118 | stage_env = list() 119 | 120 | # 提取所有的闭包环境参数 121 | for envinfo in func.__closure__: 122 | content = envinfo.cell_contents 123 | if callable(content): 124 | next_stage = envinfo.cell_contents 125 | continue 126 | stage_env.append(content) 127 | 128 | env.append(tuple(stage_env)) 129 | return unclosure(next_stage, env) 130 | 131 | # 如果是包底函数 132 | return func, env 133 | 134 | 135 | def doc(func: Callable) -> str: 136 | """处理底包函数的注释""" 137 | docs = func.__doc__.split('\n') 138 | first = docs[0] if docs[0] else docs[1] 139 | exhint = first.split('-')[0].strip() 140 | return exhint 141 | 142 | 143 | def pointname(env: List[Tuple]) -> str: 144 | """ 145 | 从闭包的信息中匹配访问点名称 146 | 所有的接入点名称以 leaf. 开头 147 | 当未找到对应的接入点名称时返回 None 148 | """ 149 | for layer in env: 150 | for info in layer: 151 | if isinstance(info, str) and info.startswith("leaf."): 152 | return info 153 | return None 154 | 155 | 156 | def reload() -> NoReturn: 157 | """加载所有的接入点信息""" 158 | # pylint: disable=global-statement 159 | global accesspoints 160 | 161 | for route, func in urlmap.items(): 162 | bottom, env = unclosure(func) 163 | description = doc(bottom) 164 | name = pointname(env) 165 | 166 | # 跳过无接入点函数 167 | if name is None: 168 | continue 169 | 170 | accesspoints.append(( 171 | name, description, route.path, 172 | ", ".join(route.methods) 173 | )) 174 | 175 | sorted(accesspoints, key=lambda item: item[0]) 176 | 177 | 178 | def export(filename: str, _type: str) -> NoReturn: 179 | """导出文件""" 180 | 181 | content = '' 182 | if _type == "markdown": 183 | content = __markdown(accesspoints) 184 | if _type == "json": 185 | content = __json(accesspoints) 186 | 187 | with open(filename, 'w', encoding="utf-8") as handler: 188 | handler.write(content) 189 | 190 | # 完成导出, 切换出当前目录 191 | print("错误代码已导出到文件 '" + filename + "'") 192 | 193 | 194 | reload() 195 | if __name__ == "__main__": 196 | args = __parser.parse_args() 197 | export(args.export, args.type) 198 | -------------------------------------------------------------------------------- /docs/accesspoints.md: -------------------------------------------------------------------------------- 1 | 2 | 以下是 _Leaf_ 现有的所有接入点信息及其介绍: 3 | 4 | | 接入点代码 | 接入点描述 | 接入点路径 | 允许的请求方式 | 5 | | :------ | | :---- | | :----- | | :----------- | 6 | | leaf.views.rbac.user.get | 批量获取用户信息 | /rbac/users | HEAD, OPTIONS, GET | 7 | | leaf.views.rbac.user.get | 根据用户 ID 查询用户 | /rbac/users/ | HEAD, OPTIONS, GET | 8 | | leaf.views.rbac.user.get | 根据用户 Index 查询用户 | /rbac/users// | HEAD, OPTIONS, GET | 9 | | leaf.views.rbac.user.update | 更新用户 informations 信息 | /rbac/users//informations | PUT, OPTIONS | 10 | | leaf.views.rbac.user.create | 创建一个用户的接口调用顺序如下: | /rbac/users | POST, OPTIONS | 11 | | leaf.views.rbac.user.update | 更新一个用户的状态 | /rbac/users//status | PUT, OPTIONS | 12 | | leaf.views.rbac.user.update | 将用户添加至用户组 | /rbac/users//groups/ | POST, OPTIONS | 13 | | leaf.views.rbac.user.update | 为指定用户增加一个索引信息 | /rbac/users//indexs | POST, OPTIONS | 14 | | leaf.views.rbac.user.update | 删除用户的一种指定索引 | /rbac/users//indexs/ | OPTIONS, DELETE | 15 | | leaf.views.rbac.user.delete | 删除某一个用户 | /rbac/users/ | OPTIONS, DELETE | 16 | | leaf.views.rbac.user.update | 将用户从组中移除 | /rbac/users//groups/ | OPTIONS, DELETE | 17 | | leaf.views.rbac.user.update | 更新用户头像 | /rbac/users/avatar/ | POST, OPTIONS | 18 | | leaf.views.rbac.user.update | 删除用户头像 | /rbac/users/avatar/ | OPTIONS, DELETE | 19 | | leaf.views.rbac.group.query | 根据给定的 id 查找用户组 | /rbac/groups/ | HEAD, OPTIONS, GET | 20 | | leaf.views.rbac.group.list | 列出所有的用户组信息 | /rbac/groups | HEAD, OPTIONS, GET | 21 | | leaf.views.rbac.group.query | 根据名称查找指定的用户组 | /rbac/groups/name/ | HEAD, OPTIONS, GET | 22 | | leaf.views.rbac.group.delete | 删除某一个特定的用户组 | /rbac/groups/ | OPTIONS, DELETE | 23 | | leaf.views.rbac.group.add | "增加一个用户组 | /rbac/groups | POST, OPTIONS | 24 | | leaf.views.rbac.group.update | 更新某一个用户组的信息 | /rbac/groups/ | PUT, OPTIONS | 25 | | leaf.views.rbac.group.edituser | 编辑用户组中的用户: | /rbac/groups//users | PUT, OPTIONS | 26 | | leaf.views.rbac.auth.create | 根据用户的某一个 index 创建 Auth 文档 | /rbac/auths// | POST, OPTIONS | 27 | | leaf.views.rbac.atuh.update | 更新用户的认证文档状态 | /rbac/auths//status | PUT, OPTIONS | 28 | | leaf.views.rbac.auth.delete | 删除用户的某一种认证方式 | /rbac/auths// | OPTIONS, DELETE | 29 | | leaf.views.rbac.auth.get | 查询用户的索引与认证文档的对应状态 | /rbac/auths/ | HEAD, OPTIONS, GET | 30 | | leaf.views.rbac.auth.update | 更新用户密码 | /rbac/auths//password | PUT, OPTIONS | 31 | | leaf.views.rbac.accesspoint.query | 根据指定的名称查找相关的访问点信息 | /rbac/accesspoints/ | HEAD, OPTIONS, GET | 32 | | leaf.views.rbac.accesspoint.get | 返回所有的访问点信息 | /rbac/accesspoints | HEAD, OPTIONS, GET | 33 | | leaf.views.rbac.accesspoint.delete | 删除某一个访问点信息 | /rbac/accesspoints/ | OPTIONS, DELETE | 34 | | leaf.views.rbac.accesspoint.create | 创建一个访问点信息 | /rbac/accesspoints | POST, OPTIONS | 35 | | leaf.views.rbac.accesspoint.update | 更新某一个访问点信息 | /rbac/accesspoints/ | PUT, OPTIONS | 36 | | leaf.views.rbac.accesspoint.update | 为指定的 AccessPoint 管理特权用户 | /rbac/accesspoints//exceptions | PUT, OPTIONS | 37 | | leaf.views.commodity.product.get | 批量获取产品信息 | /commodity/products | HEAD, OPTIONS, GET | 38 | | leaf.views.commodity.product.get | 根据产品ID查找产品 | /commodity/products/ | HEAD, OPTIONS, GET | 39 | | leaf.views.commodity.product.get | 根据产品名查找全部相关的的产品 | /commodity/products/name/ | HEAD, OPTIONS, GET | 40 | | leaf.views.commodity.product.get | 根据产品标签查找产品 | /commodity/products/tags/ | HEAD, OPTIONS, GET | 41 | | leaf.views.commodity.product.get | 查询全部的标签以及个数 | /commodity/products/tags | HEAD, OPTIONS, GET | 42 | | leaf.views.commodity.product.delete | 删除一个指定的产品 | /commodity/products/ | OPTIONS, DELETE | 43 | | leaf.views.commodity.product.update | 删除一个指定的产品参数 | /commodity/products//parameters/ | OPTIONS, DELETE | 44 | | leaf.views.commodity.product.update | 增加一个产品参数 | /commodity/products//parameters | POST, OPTIONS | 45 | | leaf.views.commodity.product.update | 更新产品在售状态 | /commodity/products//onsale | PUT, OPTIONS | 46 | | leaf.views.commodity.product.create | 创建一个产品 | /commodity/products | POST, OPTIONS | 47 | | leaf.views.commodity.product.update | 更新产品信息 | /commodity/products/ | PUT, OPTIONS | 48 | | leaf.views.commodity.product.get | 获取当前产品已经生成的所有商品列表 | /commodity/products//goods | HEAD, OPTIONS, GET | 49 | | leaf.views.commodity.product.update | 生成商品列表 | /commodity/products//goods | POST, OPTIONS | 50 | | leaf.views.commodity.product.update | 清除所有的生成商品 | /commodity/products//goods | OPTIONS, DELETE | 51 | | leaf.views.commodity.product.get | 查询所有的独立商品 | /commodity/goods | HEAD, OPTIONS, GET | 52 | | leaf.views.commodity.product.get | 根据标签查询所有的独立商品 | /commodity/goods/tags/ | HEAD, OPTIONS, GET | 53 | | leaf.views.commodity.product.get | 根据名称查询所有的独立商品 | /commodity/goods/name/ | HEAD, OPTIONS, GET | 54 | | leaf.views.commodity.product.update | 更新一个特定的产品信息 | /commodity/goods/ | PUT, OPTIONS | 55 | | leaf.views.commodity.product.create | 创建一个独立商品 | /commodity/goods | POST, OPTIONS | 56 | | leaf.views.commodity.product.update | 更改商品标签 | /commodity/goods//tags | PUT, OPTIONS | 57 | | leaf.views.commodity.product.update | 更新商品在售状态 | /commodity/goods//onsale | PUT, OPTIONS | 58 | | leaf.views.commodity.product.get | 查询一个指定的商品信息 | /commodity/goods/ | HEAD, OPTIONS, GET | 59 | | leaf.views.commodity.product.delete | 删除一个指定的商品 | /commodity/goods/ | OPTIONS, DELETE | -------------------------------------------------------------------------------- /docs/deployment.md: -------------------------------------------------------------------------------- 1 | # Leaf 的安装与部署 2 | 3 | ## 安装 4 | 5 | `Leaf` 目前提供三种安装方式: 6 | 7 | 1. 直接拷贝源代码 8 | 通过源代码安装只需要克隆本项目在 GitHub 上的源代码即可 - 通过这种方式安装的源代码需要拷贝到您的工作目录之中,但是它并不会添加到您的 `Python` 环境变量中: 9 | 10 | ```shell 11 | git clone https://github.com/guiqiqi/leaf 12 | cp leaf/leaf . -r 13 | ``` 14 | 15 | 2. 通过源代码安装 16 | 通过源代码安装,步骤与上面类似,但是会添加到您的 `Python` 环境变量中: 17 | 18 | ```shell 19 | git clone https://github.com/guiqiqi/leaf 20 | cd leaf 21 | python setup.py install 22 | ``` 23 | 24 | 3. 通过 Pypi 自动安装(推荐) 25 | 通过 `Pypi` 的发布包安装可能会比最新的代码落后一些版本,但是十分方便,您只需要执行: 26 | ```shell 27 | pip install wxleaf 28 | ``` 29 | 30 | ## 部署 31 | 32 | `Leaf` 的部署十分简单,只需要三个步骤: 33 | 34 | 1. 创建一个您的项目文件夹,用于保存您项目相关的文件 35 | 2. 编写配置文件 - 可以直接参考项目 `config.py` 进行修改即可 36 | 3. 编写运行文件 - 参考 `run.py` 内的注释,选择自己需要加载的模块即可 37 | 38 | `Leaf` 基于 `Flask` 包,它的部署就如同任何一个普通的 `Flaks` 应用一样:当您只是想在本地调试代码时,可以直接启动 `run.py` 中自带的 `werkzeug` 的应用: 39 | 40 | ```Python 41 | server.run(host="localhost", port=80) 42 | ``` 43 | 44 | 当需要在生产环境中运行时,推荐使用 `Gunicorn` 部署,示例命令: 45 | 46 | ```shell 47 | gunicorn -w 4 -b 0.0.0.0:80 run:server --daemon 48 | ``` 49 | 50 | ***启动多个进程不会造成应用内的任务调度系统执行多次任务吗?*** 51 | 您无需担心,我们已经在代码实现中解决了这个问题。 52 | 53 | 更多的参数配置请参考 `Gunicorn` 的[官方使用文档](https://docs.gunicorn.org/en/stable/)。 54 | 55 | 同时,我们也建议您在生产环境中使用 `Nginx` 作为代理转发,提升静态资源的访问性能。 56 | -------------------------------------------------------------------------------- /docs/errcode.md: -------------------------------------------------------------------------------- 1 | 2 | 以下是 _Leaf_ 的现有的所有错误代码及其描述: 3 | 4 | | 错误代码 | 错误描述 | 所属模块 | 5 | | :----: | | :----: | :----: | 6 | | 10010 | 非法的 ObjectId 字符串 | leaf.api.error | 7 | | 10011 | 项目达到最大注册数量限制 | leaf.core.events | 8 | | 10012 | 非法根节点名称 | leaf.core.events | 9 | | 10013 | 非法事件名称 | leaf.core.events | 10 | | 10014 | 未找到对应的事件 | leaf.core.events | 11 | | 10015 | 无法找到该用户的Id验证文档 | leaf.rbac.error | 12 | | 10016 | 创建/更新身份验证-密码验证失败 | leaf.rbac.error | 13 | | 10017 | 根据给定的文档索引无法查找到身份验证文档 | leaf.rbac.error | 14 | | 10018 | 根据给定信息找不到用户 | leaf.rbac.error | 15 | | 10019 | 用户初始化已经完成 | leaf.rbac.error | 16 | | 10020 | 根据给定的信息找不到访问点文档 | leaf.rbac.error | 17 | | 10021 | 根据给定的信息找不到用户组文档 | leaf.rbac.error | 18 | | 10022 | 您所给定的用户索引信息已经被绑定 | leaf.rbac.error | 19 | | 10023 | 您所给定的用户索引类型已经被绑定 - 且当前策略不允许多绑定 | leaf.rbac.error | 20 | | 10024 | 不能删除根据 用户Id 创建的认证文档 | leaf.rbac.error | 21 | | 10025 | 根据给定信息找不到对应的产品 | leaf.selling.error | 22 | | 10026 | 找不到对应的产品参数信息 | leaf.selling.error | 23 | | 10027 | 产品参数信息发现重复 | leaf.selling.error | 24 | | 10028 | 根据给定信息找不到对应的商品 | leaf.selling.error | 25 | | 10029 | 不允许给定的货币类型进行交易 | leaf.selling.error | 26 | | 10030 | 不能用空商品列表创建订单 | leaf.selling.error | 27 | | 10031 | 商品货币类型不统一 | leaf.selling.error | 28 | | 10032 | 试图创建订单的商品已经停售 | leaf.selling.error | 29 | | 10033 | 所选商品库存不足 | leaf.selling.error | 30 | | 10034 | 无法找到支付平台通知到的订单 | leaf.selling.error | 31 | | 10101 | 插件载入时出错 | leaf.plugins.error | 32 | | 10102 | 没有找到对应的插件 | leaf.plugins.error | 33 | | 10103 | 插件 init 函数错误 | leaf.plugins.error | 34 | | 10104 | 插件运行期间出现错误 | leaf.plugins.error | 35 | | 11001 | 该状态码已经被使用, 请更换状态码 | leaf.core.algorithm.fsm | 36 | | 11002 | 当前状态不接受发生的指定事件 | leaf.core.algorithm.fsm | 37 | | 11003 | 根据当前状态和事件不能确定转移的目标状态 | leaf.core.algorithm.fsm | 38 | | 12001 | 对消息体的签名验证出现错误 | leaf.weixin.error | 39 | | 12002 | 在消息体加密过程中出现错误 | leaf.weixin.error | 40 | | 12003 | 在消息体解密过程中发生错误 | leaf.weixin.error | 41 | | 12004 | 消息体不正确(键缺少/数据类型非法) | leaf.weixin.error | 42 | | 12111 | JWT Token 头部格式错误/不支持 | leaf.rbac.error | 43 | | 12112 | JWT Token 格式错误 | leaf.rbac.error | 44 | | 12113 | JWT Token 的签名计算错误 - 检查secret是否与算法匹配 | leaf.rbac.error | 45 | | 12114 | JWT Token 签名验证错误 | leaf.rbac.error | 46 | | 12115 | JWT Token 过期 | leaf.rbac.error | 47 | | 12116 | 在 HTTP Header 信息中没有发现 JWT Token 信息 | leaf.rbac.error | 48 | | 13001 | 身份验证错误 | leaf.rbac.error | 49 | | 13003 | 传入的例外用户组无法被识别/用户ID错误 | leaf.rbac.error | 50 | | 13004 | 访问点名称冲突 | leaf.rbac.error | 51 | | 13005 | 未定义的用户索引类型 | leaf.rbac.error | 52 | | 14013 | 不正确的 APPID | leaf.weixin.accesstoken.error | 53 | | 14101 | 在获取过程中遇到网络错误 | leaf.weixin.accesstoken.error | 54 | | 14125 | 不正确的 AppSecret | leaf.weixin.accesstoken.error | 55 | | 14164 | 请在公众平台添加当前 IP 到白名单 | leaf.weixin.accesstoken.error | 56 | | 14202 | 获取到了不正确的响应 - 留意中间人攻击 | leaf.weixin.accesstoken.error | 57 | | 14203 | 经过多次重试后仍无法正常更新 AccessToken - 插件将停止运行 | leaf.weixin.accesstoken.error | 58 | | 14204 | 暂时没有缓存的 AccessToken | leaf.weixin.accesstoken.error | 59 | | 14205 | 缓存的 AccessToken 状态异常 | leaf.weixin.accesstoken.error | -------------------------------------------------------------------------------- /docs/errcode.py: -------------------------------------------------------------------------------- 1 | """ 2 | 错误代码管理器: 3 | 管理现有的错误代码与描述 4 | 生成错误代码描述文档 5 | """ 6 | 7 | # 将文件路径向上转移 8 | import os as _os 9 | import json as _json 10 | import argparse as _argparse 11 | from collections import namedtuple as _namedtuple 12 | from collections import OrderedDict as _ODict 13 | from typing import List as _List 14 | from typing import Dict as _Dict 15 | 16 | workdir: str = _os.path.dirname(_os.path.realpath(__file__)) 17 | __import__("sys").path.append(_os.path.dirname(workdir)) 18 | 19 | # pylint: disable=wrong-import-position 20 | from leaf.core import error as _error 21 | 22 | # 设定错误信息存储类 23 | ErrorInfo = _namedtuple("ErrorInfo", ("description", "module")) 24 | 25 | # 获取 error.Error 的所有子类信息 26 | __informations: _Dict[int, str] = dict() 27 | 28 | 29 | def reload(): 30 | """利用反射导出所有的错误信息""" 31 | __subclasses: _List[_error.Error] = _error.Error.__subclasses__() 32 | for _error_class in __subclasses: 33 | info = ErrorInfo(_error_class.description, _error_class.__module__) 34 | __informations[_error_class.code] = info 35 | 36 | 37 | # markdown 表头 38 | __HEADER = \ 39 | """ 40 | 以下是 _Leaf_ 的现有的所有错误代码及其描述: 41 | 42 | | 错误代码 | 错误描述 | 所属模块 | 43 | | :----: | | :----: | :----: | 44 | """ 45 | 46 | 47 | # 命令行参数接收 48 | __parser = _argparse.ArgumentParser(description="错误代码导出") 49 | __parser.add_argument("--type", "-t", default="markdown", 50 | help="type 表示导出文件类型, 支持 json, markdown. 默认为: markdown") 51 | __parser.add_argument("--export", "-e", help="export 参数表示导出的文件名", required=True) 52 | 53 | 54 | def __makeline(key: int, info: ErrorInfo) -> str: 55 | """制作新的一行""" 56 | return "| " + str(key) + " | " + \ 57 | info.description + " | " + info.module + " |" 58 | 59 | 60 | def __markdown(informations: _Dict[int, ErrorInfo]) -> str: 61 | """制作 markdown 格式的表格""" 62 | content_list = list() 63 | informations = _ODict(sorted(informations.items())) 64 | for key, value in informations.items(): 65 | content_list.append(__makeline(key, value)) 66 | return '\n'.join(content_list) 67 | 68 | 69 | def __json(informations: _Dict[int, ErrorInfo]) -> str: 70 | """制作 json 格式的数据""" 71 | errcodes: _Dict[int, _Dict[str, str]] = dict() 72 | for code, info in informations.items(): 73 | errcodes[code] = dict(info._asdict()) 74 | 75 | return _json.dumps(errcodes, indent=4, sort_keys=True, ensure_ascii=False) 76 | 77 | 78 | def export(filename: str, _type: str) -> _Dict[int, ErrorInfo]: 79 | """导出文件""" 80 | 81 | content = '' 82 | if _type == "markdown": 83 | content = __HEADER + __markdown(__informations) 84 | if _type == "json": 85 | content = __json(__informations) 86 | 87 | with open(filename, 'w', encoding="utf-8") as handler: 88 | handler.write(content) 89 | 90 | # 完成导出, 切换出当前目录 91 | print("错误代码已导出到文件 '" + filename + "'") 92 | 93 | return __informations 94 | 95 | 96 | reload() 97 | if __name__ == "__main__": 98 | args = __parser.parse_args() 99 | export(args.export, args.type) 100 | -------------------------------------------------------------------------------- /docs/illustration/JWT验证.drawio: -------------------------------------------------------------------------------- 1 | 7Vxtc9o4EP41+hjG7y8fbSDt3NCZzNG5tp86Cgjw1SDGiAT6628lyxhbJjHEweaaTmZqrSVZ1u6zenbXCTL7y92nBK8XX+iUxMjQpjtkDpBh6IZjwX9csk8ltm+kgnkSTWWnXDCOfhMp1KR0G03JptCRURqzaF0UTuhqRSasIMNJQp+L3WY0Lj51jedEEYwnOFal36IpW6RSz3Bz+WcSzRfZk3XHT+8scdZZvslmgaf0+UhkDpHZTyhl6dVy1ycx37xsX9Jx9yfuHhaWkBWrMyDest1sRvb73w+jwfTpuz36Z3in2+k0TzjeyjdGQxsFA+S7aOiioI8CBw0tFHooFLc8D3mOuPC5JH01ts/2i0xh+2STJmxB53SF42EuDRO6XU0JX5QGrbzPiNI1CHUQ/ksY20tbwFtGQbRgy1jeJbuIfT+6/sGn6tmyNdjJmUVjnzVWLNl/zzvy5o/je/kw0crGTbbJk1grf5i641IJG7pNJuSlbZaWi5M5YS/0M9N+fBOPHiD1+YnQJYHFQQcJrzutp9u6lw5KSIxZ9FS0WyzNf34Ye5jugUbwGnkXOpttYHFHJgQXR0/NRcKwzjEyxcb++vYVBF/pL7JSLAhwsuaXsM84jklM5wleFi1gwxIYmuER3ihckySCZZKkPPAhvxE+LyJGxmssVPUM/qpgjXxinEyk2el81k16rfUM8yX1P5GEkV0dhVmOdAbSGxq2bD/nvkXXpGxx5FeycVX6LKjrXN2Y1gUQLqCiMTxrl+HZPQvQl2PYqIlhw68J4tqAfZOCDQV8g/Au2IK1nQLeFDO8YTQhJyBzpLoGIHGAgISE5VkKJOxrIkI31SOxvFVzMPp1/Zc/0BP8mM2gvbgptl7aFKvCT1gVu6Jr1jtti6fsyojiKWd2YC6qLT1HyxivuAnN6Ipl7kQcqYsono7wnm75mjcMT35lrXBBk+g39Me5p8cJk57DcPhsURz3aUy5o19R8YB80JhPJh+TEO69HzLt6CXRF7wrdByByWcLpHGM15so1RUfuATMR6uQMkaXJ/3Iy8b0Oh6kqk2npqbN9wKAr2g62mxAcMcfBo4DBpfVDe/GDudyST0VGsNxNF9BMyYzPoxvTgSUO5Bixg+OcANeJ1rNR6LPwMolf8tN4CIKY2exoNWwrinQCX4gMZwhjatqzYmO2CQ7hB/Ytj4/XmxYeB/aet6GH949YX26gnfBkdAtAdN4Jtw86mrdO1fpRk2lZ/2ad3qaonSyW0ulM2BQ/EDbraOEfCi/aeXbRsvKt1XlA5+WyhdH3c+YPPHA/kPzjWre9dqGvRqa9Xq9Dz03rOcDK2tP0Sp9E1mdexToaOggT0fBvbi4R74m0jtw4SiW0FZseI1cz0k+/2poKJNor4aGUglnpHcMPWOQ0pTudEnPupvuUeOnF9M9f16kcH6MbBXDQdNoPRy0FCV/JnjKWcPH2VEjw3tDoYGjaHoMusBsm5APZTes7PZDAUXZaZbnQ8+N6rl14m91h9rldO7H0Z3XqF2B2OU87wS1E62jUtBbSwF163lW3YLelWoBariHhj7yNOQHaOjxcMBzBfkPUCCiAAgKeLXXRcFQVIQdFJrIBy/hxBwfj3DeO3OBFJgnCJDX5/PwUboYNUDhkM8DMYU3UEyuWHx7pdaAHzcU3ooEeY0OFSp2FqqqDDZAv1y7wL4cV2VffgV4/fdK0Fp2d8B7UVzWLnjrRmtdA2/1pxoewM3iwIRoHeDGLwQAU9DxjzcOuIZbfeTf3yoMDbMYBdleyzg0OnSIXoJD/TwcXg65rLrweu3c7hbk3ArIORxCgS3PucDg55xvocAT6TMAmytuDQUsD2ehkmv7kyynYQ/u17Qm223amkq5tOyrHq3kmPRS1iVdqRyV2+RVk3Id8lVnEP43HPN1rSSbsSs+Ry2/j8kk4VahiaY2xsC9FQKepd/K8tPJmv/zh3alj4psq2YBxns3zm50B39nePzL8efUhV+3PpezKr7+qgxtwxD5pmADQ06rFXApyDmGlUTecjfnX833eEZrssAJ64kqxk/jBIYaAIZZItFuRTDrVADj3T63s9TKZEjgDcGBnagYXRs3l2PAcmuCoFtHUJaFLmBAUFrgsAAGX6SDAAOegUI/T+/whA8AQ2tdYxdxW2iUKeqlleSG+W5tM3Iat6Nqwuv4RSdilb9FPEF4myKzlhqX8RKz6qdvxSu7RnFDDcvv+cf/3HadtK0WAYUj6PPcMd9tR56KPux/mo0aIM8UfXzk36hHuHqexK5bV7C7lZrM1l36JYMHFX+t/IqBo5cS+BW/dWPbV4STc+u/dNNq+icz/ltL4NtVoYXiKasT+KLuxutx1g0n8E2veMo5pq3g8KoJfLuqpAJHWSjyu6Jc4vkfx9qbAVs3/d+1Y01NxbWu8TdooW4UYXRLCSrX7+PJoiKdeRm351TkEW9OMZEm3F4p5WJ5vko/Ktye/W70w2zdjC/6+OelCP1qzsypzdG7ldRxVI5+Yzg6fHeT4cipoPHN4Aia+R/mSLMW+Z83MYf/AQ== -------------------------------------------------------------------------------- /docs/illustration/JWT验证.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guiqiqi/leaf/79e34f4b8fba8c6fd208b5a3049103dca2064ab5/docs/illustration/JWT验证.png -------------------------------------------------------------------------------- /docs/illustration/事件管理器.drawio: -------------------------------------------------------------------------------- 1 | 7Vptb+I4EP41fGyVFxLgY4H2dk/tqtreaXc/GuISb02cOqaF/fU3jp13Q9MjBFWqhERmbE/smWc843EG7my9/YujOLxjAaYDxwq2A3c+cBzb8YfwJzk7xfEmjmKsOAl0p4LxQP5gzbQ0d0MCnFQ6CsaoIHGVuWRRhJeiwkOcs9dqt0dGq2+N0Qo3GA9LRJvcHyQQoeKOnVHB/4LJKszebPsT1bJGWWe9kiREAXstsdzrgTvjjAn1tN7OMJXKy/Sixt3sac0nxnEk2gxY//l7N/WefiXPvvs1GM1//3v3fKGlvCC60Qu+QxGsies5i12miOSVrCmKgJo+skg86BYL6GVIaHCLdmwjJ5IItHzKqGnIOPkD/RGFJhsY0MyFtrPjS2mE0hmjjAMjYukLikEPUph+DccJDLvPFmzXWHdoW+l4ixKRTZBRiuKELNIpy4FrxFckmjIh2Fp30prAXODtXhXbueEA8ZitseA76KIH5KjVYHcsTb8W0LF9zQvLsHHGGrIarqtcdmFReNBGfYeBnYaBB85U+suGq1VdAQFaFpukYXJQhEgtxtkTrpnIYDVEySoCkuJHOUxqkoAjXWm2YLEUFqMliVa3aZ/5sOB819qQLAZjH2nqLCEJAhxJozKYJFrkoIsZiUSqLW8KP1DqzLr0Bh5MfAa0XdDwk925mLEI1oJIakwM8HjFEiLtLL/ffZpwyMzvt7N+1q9z47t7jM/xkvEAnDy1/i0BLXjT6xe5Vm/+CYOOYeA5Z4aB14BBiKIAVJiqrLA7SLe+se9YbHhkAkIo1tkufipdjcbtdOVZJ9LVuLFqHEBCoElAT8hWLEL0uuACJjdRgAONyKLPLZNYT/X1Gwux01EPbQSTQa7QJt4S8bP0/EuKAtQqap7FtZTYZUQEy/1ZJkqjJFkMS6lsnFqfXNThIAc6YBu+xAd0pXMgCOgrfMjkntnkHFMkyEt1Hp0bdNQAvwL8seA+nAnUEwETru0+cT0xhIL9GqjuqunGiTmBmciYUfKC+4LbhdaGTov8yaA1f3LpnUhv2etKikPLJY4PAqge8xY6zwRXDK7koUTyKJOZbeH7l9aw4v7WcHJ4AwCipP9TbApvevYxu8fkyF1BD72XUC1BaFKH0KQqQs1Ljyofl+qChlVBdt01lXIaglKU5es5AnjNoxmcazb0aOCxOE2kApSEacyyVbMOTuO94WjidQ+wdiAx4uttbDr9AMz3e8WF+5mhtIdPa7C4o3PmKE7T1/tIUupQtkeGcNtrluL6HwXcXYLUabujnRekzVISbHFwQkewOsen8gi94PC0EsqlZRHxRiqtZxjn8DwbjJ3PPfod8Pc+BvybVZQM9aoe5VgLXVSDNv95I+v6sv6UPVYAmbfLE45X6VSAJmPqYbJWl/VXNsjRbGhSk7nQpW+s+uRU6YXKX/O1LFiwazB5gxNIHesss5C21G7flH6TjYRHOfgocapebXcv0jkoUjKbmjhWN2mkP8FavvUt8oTqOWyXD6weyTR5nOSrXSXjm4vype2/bX2+VFWXrR3F4FrlxnUMMXhsuvkaniwI+y2T6s/QvE+FrSPzWSu8brO2+Q9HUfJouMXuwdZdpkZtK+xnPr42a+xznIg9t8rdJv9+rUznmkrG/Z5hTbeualePjemd+hZBJWhWvM0zOMW/SC9MZduw1Ca3/wsdn9LETgemcnMgb3nB/Ez3kbjmlEQHsr9FxkjvhN0rvWHmMWlhiFNxnRc2otz/Wre6P5aNY9lozAug8WuUCBTJI6hlH5pVT3Nw+p7D5eWl+ZU1tzPfw9cu65u5Qfqtj9rqbEfTN2hNqPS3L5i+YCnVtAN2eX9mrEyZ7oUnJ/Pq4Tnzhiw5eFfeYJ8tb3Dbhi33rHmD9+FTQe8to6ZU/ZawywylpaWPvew7ztDN/GSmP3/rNzk5ZYEdyOJbUnXZVHyR617/Bw== -------------------------------------------------------------------------------- /docs/illustration/事件管理器.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guiqiqi/leaf/79e34f4b8fba8c6fd208b5a3049103dca2064ab5/docs/illustration/事件管理器.png -------------------------------------------------------------------------------- /docs/illustration/支付流程.drawio: -------------------------------------------------------------------------------- 1 | 7Vxtc6I6FP41+WgHCC/hIyi0e7ud7U53tu6nDltRuIPiRazaX38TiCKQAqKFuPd2Op0kBCHJ85zn5JxYAIfz7W3kLL2HcOIGQBImWwBHQJI0ScR/ScMubVB0PW2YRf4kbRKzhif/3aWNAm1d+xN3lesYh2EQ+8t842u4WLivca7NiaJwk+82DYP8U5fOzC01PL06Qbn12Z/EXtqKJC1rv3P9mbd/sqjS8c2dfWc6kpXnTMLNURO0ABxGYRinpfl26AZk7vbzkt5nf3D18GKRu4ib3GB5wsP9gzmO3+8Gj9H77cu36GWgpJ/y5gRrOmBgycA0ADKTgglMdQAsDegyMBCwVIAQMLXkGiKXLQUgI7mECwowIbB0oGvAGAELAcMChpRcGgGdPmsV7/aT607wXNNqGMVeOAsXTmBlrebrOnpzyQBEXInC9WKS1ARcy274GoZL2uVvN453FEXOOg5xkxfPA3oVz1S0G9P7k8ovUrlR9tXR9vjiaEdr5amms78K19ErHczj/Xr61/exJtw+P3sLH7rej5fBHrJONHPjin5y2o9MyNED6ELeuuHcxe+DO0Ru4MT+Wx6cDsX47NAvgwEuUCSwUfHo3U4NbfK++Loewx/udmA9oIHeFhV43UWy6HTp5Ytg4Pxl3/rx+Kh8tOi4lq05qeyOAdACKjnAXhY3rJVCveGm4q1zuCkBABcwVMy0BQE9sSEEUkYJG9nKk9nceH7sPi2dZOI2WG3yqzz1g2AYBmGU3AtNaNuqWrUIb24Uu9vKaaNXFWrCqYbJgpTWN5kiiHsz7x2pgSqcP9Hf7u6X09G9PXP++TLbuj9/yJ434MCUdsgpJsbb8UpqaI9FpU8eSQweqURSDUjIog+p1SXm16YWWUfn0ecCNJHlPE9EJHTHE6Z5FGEzJVOIYUIqKWBrZSYFZANdJPbLxLOs/QcJ14ZfsKFuQa50i4WSErtSL0cXqMur273zTUNanm9d0q3Ci63V/8JG4ZKyb9s6/rnQ/Kq86b7Ej/ALbeyQ2MYOXUT4lYbCL0t9GiLWdrws/DrAwop/yaUhMK3eDZEoFJiiCr0rP9uHwnYc23RJDfDzzd8RLs1IiekUtFCA3sh4IOCvY/4xydieQ2JTEjVVdwof4QZvsah2NOYZ/bTH0MfDyLqE0+kKv1sRPoeHtre9GgNQCkGDobYGlEJiJObBy+TIuucAJTaz7v1t61jIZC6iWgtESeUIiJWDPdVdhQyP1Qvnv9erTkRChXmN0MoSgRgKgT7LlxJZUc7TFKIkxNeiENwTWm4aqJFrCC2oSM7BbrDfJXFBcLaXz9pHnSY1ZWReidRU+y68IpPlgVZLzUC4kSWk5qGp8wTNytHnoNnADPasPagQwVf0vsXng5DTCeJTDqwgErlESvJBGinzSXGexEfbiV9+ym9P33ePm9lgpHhjO24eK6hjONShzhGj2UhkeZOniU0ZidgiaKRsJXjmIfN/jth0ErU6C4nV1g8jUURqqw22EUXO7qjDkuByxQDq4bxP3tBKcuFQSqG/As/rL0OpQIr0jT+TRlUrVRP6LlloywbmkJlL6HJnhgqLoHanjuzgHcsBZh5AQTowdDKdJp5jkebv0DDzQHgxPPyk7SrScbU+rcK0bX0dU2L7UMVoeQkkJap1HC3XC3TrP0ve+hRg+bxXEocihu2QN2/kQmgkIUiOE2pJ+MpmmcryR//P7QbcZvkQH+5XueE2C5ItzH3nmTBRz5NblHtn9xmnOQnhIPXiSUtt5vGqKNjKr29DQbUhBTWuKKg2cmkvS8FPPM0hQpW91+iPmlLZGfnzj3O0IZDWkED71eOEQayUMTMybxE/irQI5GsVvYtYkSmQsSHsWMSEtiKmkA02SkLR2GUkvWvPzV6ViHXmRyIGBysOL3NCQcT0I/EeEbuSBBvWfo/B11FqqXCUWmIkLDrWqvMzFg1MX9pHN5JNng0QP5u8VjmMzgRyD4VadjaN4BylKWUV5rB4BRl01heRLpBAL2BVTUJMxkVAe1XpdMaX3E7OrTdGcavcuoQUjWPQVo6+gNpaC8lFDkEsKpbc+/kufvLf3UUnTqXhx4ck8yz8OOfVgWNX9ZI1hyBrTz21cOxKpGDMcuPgv/iJjh2uZv9eILVS2f9ogNa/ -------------------------------------------------------------------------------- /docs/illustration/支付流程.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guiqiqi/leaf/79e34f4b8fba8c6fd208b5a3049103dca2064ab5/docs/illustration/支付流程.png -------------------------------------------------------------------------------- /docs/introduction.md: -------------------------------------------------------------------------------- 1 | # Leaf - 开源微信商城系统框架 2 | 3 | ![Python](https://img.shields.io/badge/Python-3.6%2B-blue?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAABmJLR0QA/wD/AP+gvaeTAAAD8UlEQVRoge2YTWhcVRSAv3MnaSY/SqlSNEVTartppBZN8Ye2aYwTNUh3jQqCKEoWbqKWtO6yakmoNOBWFF0EaSsFu3CKiROjRUu7aaOpolQ0hEhpQQ1pJpP37nExVUnnzcx7uS9DC/NtZjj3/N775t1zBqpUqXJbI/G4UeHg6HaD6VLlMYEtQDPQCPjANYEZFc4ZOOklvx5nYMDGEdm9gP2j20xCRoDWsCaKnlYv8RLvdlx1DZ9wsh441WD8Nd8BD0QxE2SzEd2lqU0fMj6uLikYF+OahcZdwIaV2KrweE12Z5dLfHAsQOW/5Ges0VaLbQEmw9pbeMIlPrgWYPXO/DdJc/ipKQZTv6NyPLwDucMlPkCNq4M8+gzvjG4lJ/OI7ovHZzhiKoANxsoPsXmLgNMjdCvgtGd2qHMYGC6lY/rH+hCOltLRibo+dJlOFmQStYdlT+5kSf/h060oSdAdiHyqE8nXSimWPoGDmc1GdT/oTmAj+dagkgiqR3Us+YV0Zn8LUih6Aon+L/catRdAe8m3CZVO/l+aSNBbbDG4gLfHWlR0BGhYrazySLiGTvT5YkuBBZhafYtK7Ljqrzc+y8XapJnGe4IWgk9ApdspsXD8aa137EYa28pqG+/hQHGBpC+zlojd5QqYN8gLHHn6ip5pWg8aZsO2BAkLC0h624lt0FmGRWUa5X1recgbfPI0AL43DDSVtRZZFyQueI0mMPdGbNB/EuSQr94oQ6lZkFDmqhi+qjuC6ouholitDxIXFKCq6yIcwIi9vvgq73UvhjXQM03r8XJ7mDD9iD4S1g7Ra0HiggJEyWqI/BUyWm9eZrDb028amvH9A8Be4D5KTXreEvkNijqImdkgadBNHFjpTVhV+wYDnZ5matvwvTTIXREziobxLwSKbxb4Yn4u50uUswylLmmGJAlzYtWThwXql6aCFgrfQpev/gjMl/Kmwvd567oulJYYEixHWtpYClooLOB4j69wtozDfIHKRtfMQuBh7aFii4E3saDHQrk2q96OeyC90rF0vngKAVj0E2Bm1dIqTRa4BPoRxrZJe/aDUsrB88Bg6i85MPa6wmdFdcozLO2Lb67QNjRFHwF/sPNzC50KGeD6aieyUpx6noBZNirOp3SrzsShqWQBM4i2grkfuBiX00oWkJbduSlpX5hG9ERcTt3+S1OZi9CUPauZNQ9Sm/gb3/b8b++GWwFivyVM65qnGSOT+Mvm+Gmn+Dg+QrI7NwUS7tYuZA5NnHKJD3H8Bhqzr4B+HNHqMshz0jH/h2v42GZfHa/dAWYfwqMILSh3A3Ugc6heQfgF9BwmkWZ24bz04McVu0qVKrcx/wCMzkVJcJCK+gAAAABJRU5ErkJggg==) ![License](https://img.shields.io/badge/License-Apache2-yellow?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAADYElEQVRIidWVTWjcVRTFf+dNEqRYaGJodJoUMnFlkGhcuomoUbHCVKkLKWJbcKMLoVYr9WOYCoIxiAtdVKi2VBdF2xQVtKFIbEEKLaSLoEKmTaqNaWqjFgnY5v+Oi5lJ5iMJxp0H3ua9e8+597777oP/O7TS4Viuu6klmctaygK9QHvpaBpx2lFfpC+v+1z7zt5YtcBULvM40QNIGVzcc8nBhhAgGgQTJrywIT9+7F8JOEf4NXZ+DXqwtDUJPm4454QZhKW43iH0yHo4wEZLYAZvyxd2iXI4ywhceq1zWOIBkIk+6AaOEKudFhCQzaMph23GjaDBdL7wYrVJTVkkFclBDro/JKxbpooQscyXCXGv0DywczqX2bSkwFiuu4nogaKjDwKTgnZLefCaZUWKZRiNxI+EidHveQupOoGWZC4LygCTDj5K8KvAZJRPguZWEijCX0XzMygz3d25uU6AoM0CjI+DoiN/mviyzOEY1Shpu9EBoQ9x2BoJDTV5RIjDADaP1QnY9BoIQaMVTnMAIbAVkw3QLGiT/KTsp4oEiqkQLsxfT06l75x4NgS1hah3ygyVUaQBiL5Slzz0BSAG3QsORE4G+T7DvqYQTt+SG7+2aF2YAWaWEgAgMVJN8wYcQZAgsJAA6aYS+aU9XR1K+V2gvxTRCdu7N7x54afKNp0CSKXUWisarW8NBPkU0ncAMkMV5KOIJwRrBWsRWQV9f2lPV8eigDgD4MR315VI+tTW4Qizhj8wR5TSKwClyFtsziTWthuw3XAWaFaKwQUBRR9zkawfXPUAA3EexUPgZ8BPW/74BzpmS8f9AhL4QIpXU/g34P3i2/dDC0SzqTVDgkKAjSI8UpvFqlG8x8VIu3Nj10G7XLzAHTY9K/nfxcTNRQpOGEjBcwlqBbUCzwNY+qaqFOl84ajw2xAbgsIbFptqy1XG36TaAWzvBn4X3NMI+wPsF/SCr1raueS4nnbmLVsvYWNxETyMGQ3iMkA0bQqhJ4k61LF3fKTUSRcreWIIHe258V9W+HC6sooeMNwuIFI528tfD1fS+fPrAaZez1SN9HT+vKBmXFcZ5ApDt04336HgLYZPBD8CfwHXQGPgA8Y7lvMvo+4lV6L0135WWv8Jy2awemhEjn1y7AONlHf/ASAydLsGpZYoAAAAAElFTkSuQmCC) ![Pylint](https://img.shields.io/badge/Pylint-9+%2F10-brightgreen) ![TypeHints](https://img.shields.io/badge/TypeHints-support-green) ![pypi](https://img.shields.io/badge/Pypi-v1.0.7-orange) 4 | 5 | ![Logo](https://github.com/guiqiqi/leaf/blob/dev/docs/logo-bar.png?raw=true) 6 | 7 | `Leaf` 旨在实现一个对普通用户易用、对开发者友好的 *轻型* 开源 CMS 框架;`Leaf` 基于 `Python3.6+` 构建,后端使用 `Flask` 作为基础框架、`mongoengine` 进行数据库建模。 8 | 9 | 我们希望能减少普通用户搭建微信商城的成本,同时为有开发能力的朋友提供更多样化的功能。 10 | 11 | --- 12 | 13 | ## 特性介绍 14 | 15 | ### For Users 16 | 17 | `Leaf` 希望能够给没有开发能力的普通用户提供一个基础的、易于使用和管理的微信商城系统。 18 | 如果您是没有开发经验的普通用户,`Leaf` 可以提供给您: 19 | 20 | 1. **微信公众平台的接口使用能力** 21 | 即使您没有开发经验,也可以在我们的可视化后台中轻松地编辑多媒体文章、设置群发消息、管理用户/组、设置自动回复等等。 22 | 2. **主流支付接口的使用能力** 23 | 如果您有微信支付及支付宝支付等主流支付接口的使用权限,您可以在我们的后台中轻松配置支付方式,即时应用在您的交易当中。 24 | 3. **完善的用户/组/权限管理系统** 25 | `Leaf` 实现了基于角色的用户权限控制,通过在可视化后台中对用户的角色进行编辑,您可以轻松地配置多个用户组以分配工作 —— 管理员、编辑、库存管理员、甚至普通用户都可以进行分组。 26 | 4. **产品/库存/订单管理** 27 | `Leaf` 中集成了产品、商品库存、订单管理的功能,您可以轻松地管理 `SKU/SPU` 以及订单等信息。 28 | 5. **插件扩展能力** 29 | 您可以在我们的项目网站上寻找合适您需求的插件。 30 | 通过简单的单击按钮,您就可以启用或者禁用这些扩展,插件也可以使用上述的能力 —— 您可以使用插件设置定时任务,来进行微信推送、也可以使用插件来扩展商城功能,设置促销等等... 31 | 32 | --- 33 | 34 | ### For Developers 35 | 如果您有开发能力,`Leaf` 则能带给您更多可能,通过简单的二次开发,您可以使用到她的更多高级功能,下面是您可能感兴趣的部分: 36 | 37 | #### 功能特性 38 | 1. **任务计划支持** 39 | 不需要第三方的组件,您可以在开发过程中调用 `leaf.core.schedule` 模块,她可以帮助您实现 *轻量级* 的任务计划调度。 40 | 2. **事件机制** 41 | 您可以在代码的任何地方创建一个事件实例,通过 `events.hook` 方法可以将您的动作挂载到事件上,当事件发生时您的动作将会自动执行;在最近的版本中我们加入了多进程协作,您的事件甚至可以同步到远端的 `Leaf` 进程上执行。 42 | 3. **日志与错误系统** 43 | `Leaf` 基于 `logging` 包实现了较为完善的日志系统,同时定义的异常基类规范了系统中的异常使用。您只需要在您的代码中继承 `leaf.core.Error` 类,就可以在日志、API网关中得到详细的错误栈信息。 44 | 4. **权限控制** 45 | 正如上面在介绍所说,`Leaf` 实现了基于用户角色的权限控制,您也可以像系统应用一样定义自己的接入点,并通过简单的装饰器控制。 46 | 5. **插件系统** 47 | 插件系统是 `Leaf` 扩展性的重要保证。在 `Leaf` 中您可以: 48 | - **热插拔** 地管理插件,代码的更改变动可以仅通过一次插件重入得到部署; 49 | - 插件可以像在`Flask`的应用中设置路由一样,通过简单的装饰器控制视图路径、权限、访问IP等等 50 | - 插件可以调用系统中其余的资源(包括事件调度、日志、微信能力、数据库,甚至是其他插件的资源) 51 | 52 | #### 开发特性 53 | 1. **关于注释** 54 | 我们深知没有注释的代码等于天书这个道理,为了方便您的二次开发,`Leaf` 从核心模块到视图函数都有详细的注释。 55 | 2. **关于文档** 56 | 对于一些重要的系统功能,`Leaf` 会编写专门的文档进行说明 —— 例如如何开始您的插件开发,必要的地方还会配有插图,用于方便您的理解。 57 | 3. **类型提示** 58 | `Python` 从 3.5 版本之后支持了类型提示 `type hinting`,而我们则尽可能的在代码的各个部分使用这项全新功能,配合 `Visual Studio` 等 IDE,帮助您更轻松的开发。 59 | 4. **代码风格** 60 | `Leaf` 在开发过程我们尽可能的维持一致的代码风格,并且设置 `commit-Hook` 使用 `PyLint` 进行代码评分 —— 带目前为止,`Leaf` 获得的评分在 **9分** 以上,我们知道评分不能代表一切,但是仍希望能够做的更好。 61 | 5. **代码示例** 62 | >古人说: 纸上得来终觉浅,绝知此事要躬行。 63 | 64 | 我们会编写一些示例代码助于开发者的理解。 65 | 66 | **示例代码将会在 `demo` 文件夹中不断更新**,您也可以在 `Leaf` 的任意版本中找到示例插件(`Access Token` 中控插件)的代码,相信会更有助您的二次开发。 -------------------------------------------------------------------------------- /docs/logo-bar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guiqiqi/leaf/79e34f4b8fba8c6fd208b5a3049103dca2064ab5/docs/logo-bar.png -------------------------------------------------------------------------------- /docs/other.md: -------------------------------------------------------------------------------- 1 | # 其他问题 2 | 3 | 这里说明一些关于 `Leaf` 的其他问题。 4 | 5 | 1. 项目的名称为什么有 `leaf` 和 `wxleaf` 两个版本呢? 6 | 7 | 项目开发时没有考虑到在 `Pypi` 上 *leaf* 这个名称已被占用,而项目与微信相关,于是便添加了 *wx* 作为前缀。所以您可以叫它 `wxleaf` ,在安装之后的调用使用 `leaf` 即可。 8 | 9 | 10 | 11 | 2. 项目为什么需要如此新的 `Python` 版本? 12 | 13 | 项目完全使用了 `TypeHinting` 的代码提示特性,而这个特性在 `Python3.6+` 的版本才被完全支持,所以我们才需要新的版本支持; 14 | 15 | 当然,我相信对大家来说安装 `Python3.6+` 不算难事,毕竟都2020年了。 16 | 17 | 18 | 19 | 3. 我可以去哪里反馈问题? 20 | 21 | 您可以到项目的 [GitHub 代码仓库](https://github.com/guiqiqi/leaf) 通过 Issue 的方式提交问题,记得查看 Issue 的模板,确保自己的提问符合要求,也方便我第一时间跟进。 -------------------------------------------------------------------------------- /docs/public/logo-bar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guiqiqi/leaf/79e34f4b8fba8c6fd208b5a3049103dca2064ab5/docs/public/logo-bar.png -------------------------------------------------------------------------------- /docs/public/logo.old.drawio: -------------------------------------------------------------------------------- 1 | 5Zhbb5swFMc/TR5bcQlO8piQsE1q1UmZ1HUvkwMOuAXMjMlln37HYAIEkrRdUastL8F/X8/vHB8bBqYd7T5xnAS3zCPhwNC83cCcDwxDN9AQ/qSyV8oI6YXic+oprRKW9DdRoqbUjHokbTQUjIWCJk3RZXFMXNHQMOds22y2ZmFz1gT7pCUsXRy21XvqiaBQx8ao0j8T6gflzDqaFDURLhsrS9IAe2xbk8zFwLQ5Y6J4inY2CSW9kkvRzzlRe1gYJ7F4TofEoxa+17zsEXk/gtnsi/3TuSrt2OAwUxar1Yp9icDnLEvas6kFbAgXZNflC7wqR6jMhUAhLCKC76HdtgKqI0UpqMO0lIiVE/1D38pOeFCmvsBs/bLVMArEGBRm24AKskywK2u2EOegBSKCCec6POI0KQJvTXcEJp2taRjaLGQ8H8i0nfnUgcXO2gjPu+QYbBug2lRDhanG0+jAafRF07hM8y8ZOo5jT1EvDMvaEuK+Ceu9mJqXmcK2jD1JK0d4gfAR0MnCcebjPoGaw/cFqKP2lvYgtasi4yJgPotxuKjUmZvxzYFoxVeDUtXhhrFENXkkQuzVoYUzwZrMyY6K76q7fH6oPc939YIMOr3s8pVwCgwIL1vEwCMf59oqiw/lFLJQjZWX9qr0QtemLOMuOdNQARWY++TcgCq5StpnA4WTEAu6aR60b5+chq+Ig/5dr4/rvteukTZCE0u3htYYjUw0/kdiwfxYsfCanNB/LJjoWXmgkZzey6HGh3IounxK1hzRdUS2bh5wTU9kPU2Zna1kAKyw++TnUXCXiZDGpPtIhTuK4Rh9HqmoeUXR9fYdxUTtI3bY1xH7jHcHHrBolaUvvp84DtKnsz5hTpowTavNsuO6YvZ2h7Y+ZGr6T46p0YfKambXCxUKhfQdzIl8kaMqlDUDVPW4Qb8yVlZcpbnnp9BgZCQ7+Mt7anndVsW1rI0Zj3BYdZZtsNsc0GYRdaFiieMU/m6XjebuYfNWoolGeDVZ16Vi8TcEr0sDAFFhQ9MukNvGJiesb631JnOph6HGZnHKgEx9qRdZ6UPJ6pJxk/zXNu4uIbHEVAQnLEGSgsQCecXQ7okdYLlkHHt5qO8jUjgwSxLYwdeXsdQgHGUMSH2iucFTwdkTKRNrzOTh1ci1SkohMdPYB8GqSt/yLHKVB01X+maQbddh/k0roJ4HVh+nJBxSH9S5CzbK5DA7fATrvsOc+qZ0+rUddbynT97oWxIUq89zeV3tK6e5+AM= -------------------------------------------------------------------------------- /docs/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guiqiqi/leaf/79e34f4b8fba8c6fd208b5a3049103dca2064ab5/docs/public/logo.png -------------------------------------------------------------------------------- /docs/public/social-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guiqiqi/leaf/79e34f4b8fba8c6fd208b5a3049103dca2064ab5/docs/public/social-page.png -------------------------------------------------------------------------------- /leaf/api/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Leaf 框架 API 支持: 3 | wrap - 一个返回值 API 包装器 4 | settings - API 相关设置 5 | converter - 类型转换器注册表 6 | iplimit - 基于 IP 地址的访问过滤 7 | """ 8 | 9 | from . import error 10 | from . import settings 11 | from . import validator 12 | from . import wrapper 13 | -------------------------------------------------------------------------------- /leaf/api/error.py: -------------------------------------------------------------------------------- 1 | """API 错误定义""" 2 | 3 | from ..core import error as _error 4 | 5 | 6 | class InvalidObjectId(_error.Error): 7 | """非法的 ObjectId""" 8 | code = 10010 9 | description = "非法的 ObjectId 字符串" 10 | -------------------------------------------------------------------------------- /leaf/api/settings.py: -------------------------------------------------------------------------------- 1 | """API 接口的设置文件""" 2 | 3 | from typing import Optional 4 | from flask import abort 5 | from ..core import error 6 | 7 | 8 | class Authorization: 9 | """权限验证中的设置""" 10 | 11 | ExecuteAPMissing = True # 在未找到接入点信息时是否允许 12 | 13 | @staticmethod 14 | def UnAuthorized(_reason: error.Error): 15 | """ 16 | 验证失败时的返回值: 17 | _reason: 原因-错误类型 18 | """ 19 | return abort(403) 20 | 21 | @staticmethod 22 | def NotPermitted(_diff: int, _strict: Optional[bool] = False): 23 | """ 24 | 权限不足时的返回值: 25 | _diff: 所需权限与拥有权限的差值 26 | _strict: 是否指定需要某一级别权限值 27 | """ 28 | return abort(403) 29 | 30 | 31 | class HTTPResponseHeader: 32 | """HTTP响应头部分的设置""" 33 | AddCORSSupport = True # 是否启用 CORS 请求支持 34 | CORSDomain = '*' # 启用支持 CORS 的域设置 35 | SupportMethods = [ 36 | 'GET', 'HEAD', 'POST', 'PUT', 37 | 'DELETE', 'CONNECT', 'OPTIONS', 38 | 'TRACE', 'PATCH' 39 | ] # 支持的请求类型 - 如非必要请勿修改 40 | 41 | 42 | class Response: 43 | """响应中的设置""" 44 | Code = "code" # 错误代码键 45 | Description = "description" # 错误解释键 46 | Message = "message" # 错误消息键 47 | 48 | class Codes: 49 | """响应代码设置""" 50 | Success = 0 # 未发生错误的成功代码 51 | Unknown = -1 # 未知错误代码 52 | 53 | class Messages: 54 | """响应消息设置""" 55 | Success = "success" # 未发生错误时的成功消息 56 | Unknown = "undefined" # 未知错误消息 57 | 58 | class Descriptions: 59 | """响应解释设置""" 60 | Success = "成功" # 成功时的解释 61 | Unknown = "发生未知错误" # 未知错误解释 62 | -------------------------------------------------------------------------------- /leaf/api/validator.py: -------------------------------------------------------------------------------- 1 | """API 函数工具库""" 2 | 3 | from typing import List, Optional 4 | 5 | import bson 6 | from flask import g as _g 7 | from flask import request as _request 8 | 9 | from . import error 10 | from .. import rbac 11 | 12 | 13 | def objectid(obj: str) -> bson.ObjectId: 14 | """ 15 | 验证是否为合法的 ObjectId: 16 | 当传入的字符串为合法的 ObjectId 时返回 ObjectId 对象 17 | 否则掷出 error.InvalidObjectId 错误 18 | """ 19 | try: 20 | return bson.ObjectId(obj) 21 | except bson.errors.InvalidId as _error: 22 | raise error.InvalidObjectId(obj) 23 | 24 | 25 | def operator(modified: str) -> bson.ObjectId: 26 | """ 27 | 检查当前的操作者是否为指定对象(被修改对象): 28 | 当检查不通过时则掷出 rbac.error.AuthenticationError 29 | 当检查通过时候返回操作者的 userid: bson.ObjectId 对象 30 | """ 31 | try: 32 | checkuser: bool = _g.checkuser 33 | _operator: str = _g.operator 34 | except AttributeError as _error: 35 | raise rbac.error.AuthenticationError(modified) 36 | 37 | # 当检查到操作者和被操作者不是同一用户时 38 | if checkuser and str(modified) != _operator: 39 | raise rbac.error.AuthenticationError(operator) 40 | return objectid(modified) 41 | 42 | 43 | def jwttoken(authorization: Optional[str] = None) -> dict: 44 | """ 45 | 验证传入的 JWT Token 是否正确并返回 payload, 可能的错误: 46 | rbac.jwt.error.InvalidToken - Token 解析失败 47 | rbac.jwt.error.TokenNotFound - 未找到 Token 48 | rbac.jwt.error.InvalidHeader - 非法的头部信息 49 | rbac.jwt.error.TimeExired - Token 已经过期 50 | rbac.jwt.error.SignatureNotValid - 签名信息验证失败 51 | """ 52 | # 获取 Bearer-Token 53 | if not authorization: 54 | authorization: str = _request.headers.get("Authorization") 55 | 56 | # 通过 Authorization 头获取 Token 57 | token: str = str() 58 | if not authorization: 59 | raise rbac.error.TokenNotFound() 60 | token = authorization.replace("Bearer", '', 1) 61 | 62 | # 验证 JWT Token 信息 63 | verification = rbac.jwt.Verify(token) 64 | verification.header() 65 | payload = verification.payload() 66 | 67 | # 通过 payload 获取用户盐并判断 Token 是否正确 68 | userid = payload.get(rbac.jwt.const.Payload.Audience) 69 | salt = rbac.functions.auth.Retrieve.saltbyindex(str(userid)) 70 | verification.signature(salt) 71 | 72 | return payload 73 | 74 | 75 | def permission(pointname: str, payload: dict) -> int: 76 | """ 77 | 验证 JWT Token 的权限是否足够访问对应接入点: 78 | pointname: 接入点名称 79 | payload: JWT Token 的 Payload 部分 80 | 81 | 返回的数值 diff: int 表示所需权限差: 82 | diff == 0 - 表示权限验证符合 83 | diff < 0 - 表示所需要权限不足(返回的差表示差多少) 84 | diff > 0 - 表示所需要权限足够但该访问点要求用户组对应 85 | 86 | 可能返回的错误: 87 | rbac.functions.error.AccessPointNotFound - 未找到对应的接入点信息 88 | bson.errors.InvalidId - 非法的 UserId 信息 89 | """ 90 | # 获取权限点所需要的权限 91 | permitted: List[int] = payload.get(rbac.jwt.settings.Payload.Permission) 92 | accesspoint = rbac.functions.accesspoint.Retrieve.byname(pointname) 93 | if not accesspoint: 94 | raise rbac.error.AccessPointNotFound(pointname) 95 | userid = objectid(payload.get(rbac.jwt.const.Payload.Audience)) 96 | 97 | # 验证是否是特权用户 98 | if userid in accesspoint.exceptions: 99 | return 0 100 | 101 | # 检查是否需要指定用户组 102 | if accesspoint.strict: 103 | if not accesspoint.required in permitted: 104 | return max(permitted) - accesspoint.required 105 | return 0 106 | 107 | # 检查权限是否足够 108 | if accesspoint.required > max(permitted): 109 | return max(permitted) - accesspoint.required 110 | return 0 111 | -------------------------------------------------------------------------------- /leaf/core/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Leaf 框架核心部分: 3 | tools - 常用的工具函数库封装 4 | algorithm - 常用的数据结构算法封装 5 | 6 | error - 错误与日志记录 7 | events - 一个事件管理器 8 | wrapper - 常用的装饰器封装 9 | database - 提供一个连接池模块+MongoDB连接池 10 | schedule - 提供一个任务计划调度模块 11 | 12 | modules - 一个用于保存运行时实例的字典 13 | """ 14 | 15 | from . import tools 16 | from . import abstract 17 | from . import algorithm 18 | 19 | # modules 保存 Leaf 框架运行时实例 20 | modules = algorithm.AttrDict() 21 | 22 | # pylint: disable=wrong-import-position 23 | from . import error 24 | from . import events 25 | from . import wrapper 26 | from . import database 27 | from . import schedule 28 | from . import parallel 29 | -------------------------------------------------------------------------------- /leaf/core/abstract/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Leaf 抽象类集合: 3 | payment - 支付抽象类 4 | plugins - 插件抽象类 5 | """ 6 | 7 | from . import payment 8 | from . import plugin 9 | -------------------------------------------------------------------------------- /leaf/core/abstract/payment.py: -------------------------------------------------------------------------------- 1 | """Leaf 支付方式抽象类""" 2 | 3 | 4 | class AbstractPayment: 5 | """支付方式的抽象类""" 6 | description: str = str() # 这种支付方式的名称 7 | 8 | def __hash__(self) -> int: 9 | """支付类 Hash 支持""" 10 | return hash(self.description) 11 | 12 | def __str__(self) -> str: 13 | """支付类描述字符串""" 14 | return self.description 15 | 16 | def pay(self, orderid: str, amount: float, duration: int, describe: str, 17 | currency: str, details: dict, **kwargs) -> dict: 18 | """支付类的调用接口 - 抽象接口 19 | id: str 商户订单id 20 | details: dict 商品详细信息 21 | amount: float 总价金额 22 | describe: str 商品描述 23 | currency: str 订单币种 24 | 25 | 返回 预订单 的相关信息 26 | 27 | **kwargs 中可以添加自定义的一些参数 28 | """ 29 | 30 | def query(self, orderid: str, **kwargs) -> dict: 31 | """支付类订单查询接口 - 抽象接口 32 | orderid: str 商户订单id/平台支付单id 33 | 34 | 返回 当前查询订单的相关信息 35 | 36 | **kwargs 中可以添加自定义的一些参数 37 | """ 38 | 39 | def close(self, orderid: str, **kwargs) -> dict: 40 | """支付累订单关闭接口 - 抽象接口 41 | orderid: str 商户订单id/平台支付单id 42 | 43 | 返回关闭订单相关消息 44 | 45 | **kwargs 中可以添加自定义的一些参数 46 | """ 47 | 48 | def refund(self, orderid: str, total_amount: float, refund_amount: float, 49 | refundid: str, currency: str, reason: str, **kwargs) -> dict: 50 | """支付类的退款接口 - 抽象接口 51 | orderid: str 商户订单id/平台支付单id 52 | amount: float 退款的金额 53 | refundid: str 退款id 54 | reason: str 退款原因 55 | cert: str 退款使用证书的绝对路径 56 | 57 | **kwargs 中可以添加自定义的一些参数 58 | """ 59 | 60 | def query_refund(self, orderid: str, **kwargs) -> dict: 61 | """支付类的退款查询接口 - 抽象接口 62 | orderid: str 商户订单id/平台支付单id/商户退款单号/微信退款单号 63 | 64 | **kwargs 中可以添加自定义的一些参数 65 | """ 66 | -------------------------------------------------------------------------------- /leaf/core/algorithm/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 一些基础的数据结构/算法: 3 | tree: 一套完整的树结构/算法支持 4 | concurrent_dict: 键阻塞字典 5 | """ 6 | 7 | from . import fsm 8 | from . import tree 9 | from . import keydict 10 | from . import concdict 11 | 12 | Node = tree.Node 13 | Tree = tree.Tree 14 | AttrDict = keydict.AttrDict 15 | StaticDict = keydict.StaticDict 16 | ConcurrentDict = concdict.ConcurrentDict 17 | -------------------------------------------------------------------------------- /leaf/core/algorithm/concdict.py: -------------------------------------------------------------------------------- 1 | """一个线程安全的阻塞字典""" 2 | 3 | import threading 4 | from typing import Hashable, Dict, NoReturn, Optional 5 | 6 | 7 | class ConcurrentDict: 8 | """该字典当要pop的键不存在时, 会阻塞操作线程""" 9 | 10 | def __init__(self): 11 | self.__events: Dict[Hashable, threading.Event] = dict() # 用于存储键和查询锁对 12 | self.__data: Dict[Hashable, object] = dict() # 用于存储键值对 13 | 14 | def put(self, key: Hashable, value: object): 15 | """ 16 | 向键值对中加入存储 17 | 该函数会检查 key 是否在 self.events 里 18 | 当存在时则说明有某些查询操作正在被阻塞, 此时需要对该 19 | 锁进行 set 操作, 通知对应的线程们取走数据 20 | """ 21 | # 检验字典键是否可 hash 并存储值 22 | self.__data[key] = value 23 | 24 | # 设置事件 - 释放线程的阻塞状态 25 | if key in self.__events.keys(): 26 | event = self.__events.pop(key) 27 | if not event.isSet(): 28 | event.set() 29 | 30 | def get(self, key: Hashable, timeout: Optional[int] = None) -> object: 31 | """ 32 | 从存储中返回值: 33 | 该函数会首先检查 self.data 中是否有对应的键 34 | 如果有则直接返回 35 | 否则在 self.events 中新建一个 threading.Event 36 | 对象并将其 clear, 之后进行 wait 操作阻塞线程 37 | 当 put 操作加入对应的键值对时, 该 event 对象 38 | 会被 set, 此时对应的阻塞释放, 返回相应的值 39 | 40 | *timeout: 超时时长, 超时之后触发 TimeoutError, 默认为无限大 41 | """ 42 | # 检验字典键是否可 hash 并检查值是否已经存在 43 | # assert(isinstance(key, Hashable)) 44 | if key in self.__data.keys(): 45 | return self.__data.pop(key) 46 | 47 | # 判断是否已经有 Event 对象被创建, 如有则不创建 48 | event = threading.Event() 49 | event.clear() 50 | if not key in self.__events.keys(): 51 | self.__events[key] = event 52 | else: 53 | event = self.__events[key] 54 | 55 | # 阻塞等待线程 56 | event.wait(timeout) 57 | 58 | # 当超时或者获取到之后 59 | if key in self.__data.keys(): 60 | return self.__data[key] 61 | raise TimeoutError("key '%s' not found" % key) 62 | 63 | def erase(self, key: Hashable) -> NoReturn: 64 | """从存储中移除对应的键值对""" 65 | 66 | # assert(isinstance(key, Hashable)) 67 | if key in self.__data.keys(): 68 | self.__data.pop(key) 69 | -------------------------------------------------------------------------------- /leaf/core/algorithm/keydict.py: -------------------------------------------------------------------------------- 1 | """实现一个支持通过 . 操作符访问键的字典类""" 2 | 3 | from typing import Hashable, NoReturn, Tuple 4 | # from multiprocessing.managers import BaseProxy 5 | 6 | 7 | class AttrDict(dict): 8 | """通过重写 __getattr__ 操作符使得字典支持 . 操作符访问""" 9 | 10 | _functions = { 11 | "keys", "fromkeys", "values", "setdefault", "update", "get", 12 | "clear", "popitem", "copy", "items", "pop" 13 | } 14 | 15 | def __setattr__(self, key: Hashable, value: object) -> NoReturn: 16 | """向字典的值和 attr 同时添加一份""" 17 | super().__setitem__(key, value) 18 | # super().__setattr__(key, value) 19 | 20 | def __getattr__(self, key: str) -> object: 21 | """重定向至 __getitem__""" 22 | 23 | # 当访问实例函数时返回函数对象 24 | if key in self._functions: 25 | return super().__getattribute__(key) 26 | 27 | return self[key] 28 | 29 | def __getstate__(self) -> Tuple[dict, dict]: 30 | """对象的 pickle 序列化封装函数""" 31 | return self.__dict__, dict(self) 32 | 33 | def __setstate__(self, state: Tuple[dict, dict]) -> NoReturn: 34 | """对象的序列化恢复""" 35 | self.__dict__.update(state[0]) 36 | super().update(state[1]) 37 | 38 | 39 | class StaticDict(AttrDict): 40 | """继承自 AttrDict 但是不允许修改数据""" 41 | 42 | def __setattr__(self, key: Hashable, value: object): 43 | """删除设置功能""" 44 | raise RuntimeError("StaticDict cannot be set value") 45 | 46 | def __delattr__(self, key: Hashable): 47 | """删除删除功能""" 48 | raise RuntimeError("StaticDict cannot be remove value") 49 | -------------------------------------------------------------------------------- /leaf/core/database.py: -------------------------------------------------------------------------------- 1 | """Leaf 连接池实现""" 2 | 3 | import queue 4 | from typing import Callable, Optional, NoReturn 5 | 6 | # 自定义的数据库驱动模块 7 | import pymongo 8 | 9 | from . import wrapper 10 | 11 | 12 | class Pool: 13 | """一个通用连接池实现""" 14 | 15 | def __repr__(self) -> str: 16 | """返回repr信息""" 17 | return "" 18 | 19 | def __init__(self, size: int, 20 | creator: Callable[[], object], 21 | closer: Callable[[object], NoReturn], 22 | timeout: Optional[float] = 0): 23 | """ 24 | 连接池初始化函数实现: 25 | size: 连接池默认空闲连接 26 | creator: 创建连接函数 27 | closer: 关闭连接函数 28 | timeout: 连接超时函数 - 默认为0: 不超时 29 | """ 30 | 31 | # 通过 queue 来给出最空闲的连接 32 | self.__size = size 33 | self.__pool = queue.Queue(maxsize=size) 34 | 35 | # 构造创建函数 - 当不超时时 36 | if timeout <= 0: 37 | @wrapper.thread 38 | def _creator(): 39 | self.__pool.put(creator()) 40 | self.creator = _creator 41 | 42 | # 当设置超时 - 使用 timeout 计时器 43 | else: 44 | @wrapper.timelimit(timeout) 45 | def _creator(): 46 | self.__pool.put(creator()) 47 | self.creator = _creator 48 | 49 | # 构造关闭函数 50 | self.closer = closer 51 | 52 | # 初始化连接 53 | for _index in range(size): 54 | self.creator() 55 | 56 | def __del__(self): 57 | """析构时关闭所有链接""" 58 | self.stop() 59 | 60 | def stop(self) -> NoReturn: 61 | """ 62 | 关闭所有链接 63 | """ 64 | while self.__pool.qsize(): 65 | self.closer(self.__pool.get()) 66 | 67 | def get(self) -> object: 68 | """获取一个数据库连接""" 69 | connection = self.__pool.get() 70 | return connection 71 | 72 | def put(self, connection: object) -> NoReturn: 73 | """归还一个数据库连接""" 74 | self.__pool.put(connection) 75 | 76 | def status(self) -> float: 77 | """返回当前连接池的忙碌状态""" 78 | return 1 - self.__pool.qsize() / self.__size 79 | 80 | 81 | class MongoDBPool(Pool): 82 | """MongoDB 数据库连接池 - 继承自连接池""" 83 | 84 | @staticmethod 85 | def _create_connection(server: str, port: int, username: str, 86 | password: str, timeout: float, **optional) -> pymongo.MongoClient: 87 | """ 88 | 创建一个 MongoDB 链接的静态函数: 89 | server: 数据库地址 90 | port: 数据库端口 91 | username: 用户名 92 | password: 密码 93 | timeout: 超时时间按照毫秒计算 94 | **optional: 可选项, 会被传递给 pymongo 驱动 95 | """ 96 | return pymongo.MongoClient( 97 | server, port, username=username, 98 | password=password, **optional, 99 | serverSelectionTimeoutMS=timeout 100 | ) 101 | 102 | @staticmethod 103 | def _close_connection(connection: pymongo.MongoClient) -> NoReturn: 104 | """关闭一个 MongoDB 的链接""" 105 | connection.close() 106 | 107 | def __init__(self, size: int, server: str, port: int, 108 | username: str, password: str, timeout: float, **optional): 109 | """ 110 | 重写 Pool 的初始化函数 111 | *注意: timeout - 按照秒计算的超时时间 112 | *注意: database - 返回的直接选择的数据库 113 | *注意: **optional - 会被直接传递给 pymongo 驱动 114 | """ 115 | 116 | # 创建连接与选择器函数包装 117 | def creater() -> pymongo.MongoClient: 118 | return self._create_connection( 119 | server, port, username, password, 120 | timeout * 1000, **optional) 121 | 122 | closer = self._close_connection 123 | 124 | # 初始化父类函数 125 | super().__init__(size, creater, closer, timeout) 126 | 127 | def version(self): 128 | """获取数据库版本""" 129 | connection = super().get() 130 | version = connection.server_info.get("version", "unknwon") 131 | super().put(connection) 132 | return version 133 | 134 | def get(self) -> pymongo.MongoClient: 135 | """获取一个数据库对象""" 136 | connection = super().get() 137 | return connection 138 | 139 | 140 | class MongoEnginePool(MongoDBPool): 141 | """使用 MongoEngine 管理链接不需要手动管理池""" 142 | -------------------------------------------------------------------------------- /leaf/core/schedule.py: -------------------------------------------------------------------------------- 1 | """Leaf 任务计划与调度""" 2 | 3 | import uuid 4 | import time 5 | import threading 6 | 7 | from typing import Dict, Callable, Optional, Generator, NoReturn 8 | 9 | # 时间间隔常量定义 10 | MINUTE = 60 # 分钟间隔 11 | HOUR = MINUTE * 60 # 小时间隔 12 | DAY = HOUR * 24 # 每天间隔 13 | WEEK = DAY * 7 # 一周间隔 14 | MONTH = DAY * 30 # 每月间隔 15 | 16 | 17 | class Worker: 18 | """工作任务类""" 19 | 20 | def __repr__(self) -> str: 21 | """返回 repr 信息""" 22 | return "" 23 | 24 | def __init__(self, task: Callable[[], object], interval: float, 25 | runtimes: Optional[int] = 0, instantly: Optional[bool] = True, 26 | id_: Optional[str] = None, 27 | fineness: Optional[float] = 1): 28 | """ 29 | 任务类初始化函数 30 | task: 需要运行的任务函数 - 需要经过包装(无参) 31 | interval: 每次运行的时间间隔 32 | instantly: 创建任务时是否立即运行 33 | runtimes: 可以指定任务运行指定次数后终止 - 默认为0 - 无限次 34 | id: 需要时可以手动指定该任务的id - 默认为自动生成 35 | fineness: 检查任务是否终止的细度 - 默认为1s 36 | """ 37 | # 保存任务信息 38 | self.__task = task 39 | self.__interval = interval 40 | self.__fineness = fineness 41 | self.__instantly = instantly 42 | 43 | # 初始化任务变量 44 | self.__status = False # 任务是否在运行 45 | self.__runtimes = runtimes # 已经运行过的次数 46 | # self.__results = queue.Queue() # 任务返回值存储 47 | if id_ is None: 48 | self.__id = uuid.uuid1().hex 49 | else: 50 | self.__id = id_ 51 | 52 | def _blocker(self) -> Generator: 53 | """ 54 | 使用 yield 关键字实现协程间隔: 55 | 每次的阻塞过程分成许多个最小单位 56 | 每个最小单位的阻塞时间为 self.__fineness 57 | 之后检测是否已经阻塞足够长时间 + 是否被取消任务: 58 | 如果足够 - 运行任务 59 | 不够 - 继续休眠 60 | 被取消 - 终止 61 | """ 62 | # 记录总的休眠时间 63 | gap: float = self.__interval 64 | 65 | while self.__status: 66 | # 如果休眠时长足够 67 | if gap <= 0: 68 | gap = self.__interval 69 | yield 70 | 71 | # 否则继续休眠 72 | time.sleep(self.__fineness) 73 | gap -= self.__fineness 74 | 75 | def _do(self) -> NoReturn: 76 | """ 77 | 任务包装的运行函数: 78 | 通过循环 + 阻塞器实现 79 | 当任务次数达到之后设置运行标志位为 False 80 | 则会在下一次运行时退出循环 81 | """ 82 | # 初始化阻塞器 83 | runtimes = self.__runtimes 84 | blocker = self._blocker() 85 | 86 | # 检测任务状态 87 | while self.__status: 88 | 89 | # 检测运行次数是否达到 90 | runtimes -= 1 91 | if runtimes == 0: 92 | self.stop() 93 | 94 | # 执行任务 95 | if not self.__instantly: 96 | self.__instantly = True 97 | runtimes += 1 98 | else: 99 | self.__task() 100 | 101 | # 进行任务休眠 - 休眠失败则退出执行 102 | try: 103 | blocker.send(None) 104 | except StopIteration as _error: 105 | break 106 | 107 | blocker.close() 108 | 109 | def start(self) -> NoReturn: 110 | """设置任务启动标志位并启动任务线程""" 111 | # 如果已经启动 112 | if self.__status is True: 113 | return 114 | 115 | # 如果还未启动 - 初始化线程并启动 116 | self.__status = True 117 | worker = threading.Thread(target=self._do) 118 | worker.setDaemon(True) 119 | worker.start() 120 | 121 | def stop(self) -> NoReturn: 122 | """设置任务终止标志位""" 123 | self.__status = False 124 | 125 | @property 126 | def interval(self) -> float: 127 | """返回任务间隔时长""" 128 | return self.__interval 129 | 130 | @interval.setter 131 | def interval(self, new: float) -> NoReturn: 132 | """重设任务间隔时长 - 下次运行时生效""" 133 | self.__interval = new 134 | 135 | @property 136 | def id(self) -> str: 137 | """返回任务id""" 138 | return self.__id 139 | 140 | 141 | class Manager: 142 | """通过字典来实现任务管理与记录""" 143 | 144 | def __repr__(self) -> str: 145 | """返回 repr 信息""" 146 | return "" 147 | 148 | def __init__(self, disable: Optional[bool] = False): 149 | """ 150 | 通过保存一个字典来记录任务类 151 | disable 变量设置为 True 时不允许任何计划任务运行 152 | """ 153 | self.__disable = disable 154 | self.__tasks: Dict[str, Worker] = dict() 155 | 156 | def get(self, id_: str) -> Worker: 157 | """根据任务 id 获取任务类""" 158 | return self.__tasks.get(id_) 159 | 160 | def start(self, worker: Worker) -> NoReturn: 161 | """新增一个工作类实例并启动该任务""" 162 | self.__tasks[worker.id] = worker 163 | 164 | # 检测到有其他进程运行任务时不运行任务 165 | if not self.__disable: 166 | worker.start() 167 | -------------------------------------------------------------------------------- /leaf/core/tools/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 常用函数工具库封装: 3 | web - 网络功能库 4 | encrypt - 加密功能库 5 | file - 文件功能库 6 | time - 时间功能库 7 | """ 8 | 9 | from . import web 10 | from . import time 11 | from . import file 12 | from . import encrypt 13 | 14 | web = web.WebTools 15 | time = time.TimeTools 16 | file = file.FileTools 17 | encrypt = encrypt.EncryptTools 18 | -------------------------------------------------------------------------------- /leaf/core/tools/file.py: -------------------------------------------------------------------------------- 1 | """文件工具库""" 2 | 3 | import os 4 | import codecs 5 | import mimetypes 6 | import configparser 7 | 8 | from typing import List, IO, NoReturn, Tuple 9 | 10 | _DEFAULT_MIME = "application/octet-stream" 11 | 12 | 13 | class FileTools: 14 | """一些文件读取常用到的工具""" 15 | 16 | @staticmethod 17 | def isfile(source: str) -> bool: 18 | """判断某一文件是否存在 19 | 20 | *source: 文件的绝对/相对路径 21 | """ 22 | return os.path.isfile(source) 23 | 24 | @staticmethod 25 | def isdir(source: str) -> bool: 26 | """判断某一目录是否存在 27 | 28 | *source: 文件夹的绝对/相对目录 29 | """ 30 | return os.path.isdir(source) 31 | 32 | @staticmethod 33 | def dirs(location: str) -> List[str]: 34 | """获取某一路径下所有文件夹 35 | 36 | *location : 文件夹地址 37 | """ 38 | location = location.strip("\\") 39 | dirs = os.listdir(location) 40 | for name in dirs[:]: 41 | full = location + "/" + name 42 | if not os.path.isdir(full): 43 | dirs.remove(name) 44 | return dirs 45 | 46 | @staticmethod 47 | def files(location: str) -> List[str]: 48 | """获取某一文件夹下的所有文件 49 | 50 | * location : 文件夹地址 51 | """ 52 | location = location.strip("\\") 53 | files = os.listdir(location) 54 | for name in files[:]: 55 | full = location + "/" + name 56 | if os.path.isdir(full): 57 | files.remove(name) 58 | return files 59 | 60 | @staticmethod 61 | def read(location: str) -> IO[str]: 62 | """以UTF8编码读方式打开文件""" 63 | handler = codecs.open(location, "r", "utf-8") 64 | return handler 65 | 66 | @staticmethod 67 | def write(location: str) -> IO[str]: 68 | """以UTF8编码写方式打开文件""" 69 | handler = codecs.open(location, "w", "utf-8") 70 | return handler 71 | 72 | @staticmethod 73 | def mimetype(location: str) -> str: 74 | """根据文件名获取相应的mimetype""" 75 | 76 | # 默认为 数据流 模式 77 | default = _DEFAULT_MIME 78 | mimetype = mimetypes.guess_type(location)[0] 79 | 80 | # 当给不出尝试猜测的值时 81 | if not mimetype: 82 | mimetype = default 83 | return mimetype 84 | 85 | @staticmethod 86 | def read_config(handler: IO[str]) -> dict: 87 | """读取配置文件并返回字典""" 88 | # 生成 config_reader 类 89 | config_reader = configparser.ConfigParser() 90 | config_reader.read_file(handler) 91 | sections = config_reader.sections() 92 | config = dict() 93 | 94 | # 选中对应的 section 进行添加 95 | for section in sections: 96 | options = config_reader.options(section) 97 | config[section] = dict() 98 | # 选中 option 添加 99 | for option in options: 100 | value = config_reader.get(section, option) 101 | config[section][option] = value 102 | 103 | return config 104 | 105 | @staticmethod 106 | def write_config(handler: IO[str], config: dict) -> NoReturn: 107 | """将制定配置写入配置文件""" 108 | 109 | sections = config.keys() 110 | config_writer = configparser.ConfigParser() 111 | 112 | # 选中 section 113 | for section in sections: 114 | options = config[section].keys() 115 | config_writer.add_section(section) 116 | # 选中 option 117 | for option in options: 118 | value = config[section][option] 119 | config_writer.set(section, option, value) 120 | 121 | # 写入配置 122 | config_writer.write(handler) 123 | 124 | @staticmethod 125 | def edit_config(handler: IO[str], change: Tuple[str, str, str]) -> NoReturn: 126 | """按照配置修改文件""" 127 | 128 | # 创建 configparser 实例 129 | config_editor = configparser.ConfigParser() 130 | config_editor.read(handler) 131 | 132 | section = change[0] # 要更改的section 133 | option = change[1] # 要更改的option 134 | value = change[2] # 要更改的value 135 | 136 | # 如果没有有对应的 section 则创建一个 137 | if not config_editor.has_section(section): 138 | config_editor.add_section(section) 139 | config_editor.set(section, option, value) 140 | 141 | # 写入配置 142 | config_editor.write(handler) 143 | -------------------------------------------------------------------------------- /leaf/core/tools/time.py: -------------------------------------------------------------------------------- 1 | """时间工具库""" 2 | 3 | import time 4 | import datetime 5 | from typing import Optional 6 | from collections import defaultdict 7 | 8 | 9 | class TimeTools: 10 | """获取时间信息的一些函数""" 11 | 12 | @staticmethod 13 | def now(resolution: Optional[int] = 1) -> object: 14 | """ 15 | 获取当前时间戳 16 | 17 | *resolution: 18 | 时间精度, 为 10 的 n - 1 次方, 每秒产生个数 19 | 当传入 0 时不产生数据 20 | """ 21 | now = int(time.time() * resolution) 22 | return now 23 | 24 | @staticmethod 25 | def stamp(time_s: str, 26 | format_: Optional[str] = "%Y-%m-%d %H:%M:%S") -> object: 27 | """根据给定时间字符串获取时间戳""" 28 | 29 | # 调用 datetime 库 30 | time_ = datetime.datetime.strptime(time_s, format_) 31 | time_tuple = time_.timetuple() 32 | time_stamp = int(time.mktime(time_tuple)) 33 | return time_stamp 34 | 35 | @staticmethod 36 | def timestr(time_stamp: object, 37 | format_: Optional[str] = "%Y-%m-%d %H:%M:%S") -> str: 38 | """根据给定时间戳获取时间字符串""" 39 | 40 | # 调用 time 库 41 | time_stamp = int(time_stamp) 42 | time_tuple = time.localtime(time_stamp) 43 | time_string = time.strftime(format_, time_tuple) 44 | return time_string 45 | 46 | @staticmethod 47 | def nowstr(format_: Optional[str] = "%Y-%m-%d %H:%M:%S") -> str: 48 | """直接获取关于时间的字符串""" 49 | return datetime.datetime.utcnow().strftime(format_) 50 | 51 | @staticmethod 52 | def timediff(diff: int, unit: Optional[str] = "hours", resolution: Optional[str] = 0, 53 | fmt: Optional[str] = "{hours}:{minutes}:{seconds}") -> str: 54 | """ 55 | 格式化时间差的文字: 56 | diff - 时间差的整数 57 | unit - 时间格式化的最大单位: 58 | "hours" - 格式化为小时, ... 59 | resolution - 输入时间差整数的精度: 60 | 0 - 秒单位, 1 - 0.1 秒单位, 2 - 0.01 秒, ... 61 | format_ - 要格式化的字符串格式: 62 | 使用按照关键字进行格式化的方式, 63 | 支持的有: years, months, days, hours, 64 | minutes, seconds, miliseconds 65 | 例如: {hours}:{minutes}:{seconds} 66 | 67 | *注意: 选则低于自己的时间差范围外的单位而格式化 68 | 字符串中没有对应键时数据会被丢弃, 这可能会造成错误 69 | timediff(60 * 60 * 25, "days", fmt="{hours}") -> '1' 70 | """ 71 | # 将时间差转换为毫秒格式 72 | diff = diff * (10 ** (3 - resolution)) 73 | remain, flag = diff, False 74 | 75 | # 计算年月日... 76 | callfuncs = ( 77 | ("years", lambda remain: divmod(remain, 365 * 24 * 60 * 60 * 1010)), 78 | ("months", lambda remain: divmod(remain, 12 * 30 * 24 * 60 * 60 * 1000)), 79 | ("days", lambda remain: divmod(remain, 24 * 60 * 60 * 1000)), 80 | ("hours", lambda remain: divmod(remain, 60 * 60 * 1000)), 81 | ("minutes", lambda remain: divmod(remain, 60 * 1000)), 82 | ("seconds", lambda remain: divmod(remain, 1000)), 83 | ("miliseconds", lambda remain: (remain, 0)), 84 | ) 85 | 86 | values = defaultdict(int) 87 | 88 | for key, callfunc in callfuncs: 89 | 90 | # 当单位没有到达需要的格式化范围时不进行操作 91 | if flag or key == unit: 92 | value, remain = callfunc(remain) 93 | values[key], flag = int(value), True 94 | 95 | return fmt.format_map(values) 96 | -------------------------------------------------------------------------------- /leaf/core/wrapper.py: -------------------------------------------------------------------------------- 1 | """Leaf 装饰器函数库""" 2 | 3 | import time 4 | import queue 5 | import threading 6 | 7 | from typing import Callable, NoReturn 8 | 9 | 10 | def thread(function: Callable) -> object: 11 | """制造一个新线程执行指定任务""" 12 | 13 | def params(*args, **kwargs): 14 | """接受任务函数的参数""" 15 | 16 | # 通过线程执行函数 17 | def process(*args, **kwargs): 18 | """过程函数包装""" 19 | function(*args, **kwargs) 20 | 21 | _thread = threading.Thread( 22 | target=process, args=args, kwargs=kwargs) 23 | _thread.setDaemon(True) 24 | _thread.start() 25 | 26 | return params 27 | 28 | 29 | def timer(function: Callable) -> object: 30 | """计时函数 - 执行之后显示执行时间""" 31 | 32 | def wrapper(*arg, **kwargs): 33 | """参数接收器""" 34 | # 计时并执行函数 35 | start = time.time() 36 | result = function(*arg, **kwargs) 37 | end = time.time() 38 | 39 | # 显示时间 40 | used = (end - start) * 1000 41 | print("-> elapsed time: %.2f ms" % used) 42 | return result 43 | 44 | return wrapper 45 | 46 | 47 | def timelimit(limited: float) -> object: 48 | """ 49 | 限制一个函数的执行时间: 50 | 1. 创建两个线程 - 计数器 + 工作线程 51 | 2. 通过一个 threading.Lock 的锁同步工作状态 52 | 3. 如果锁释放了则判断工作是否完成 53 | 54 | *注意: 这种方法在超时之后会触发 TimeoutError 55 | *注意: 但是并不会影响工作线程的工作 - 工作线程无法被动结束 56 | """ 57 | 58 | def wrapper(function: Callable): 59 | """函数包装器""" 60 | # 初始化锁, 队列变量 61 | result = queue.Queue(maxsize=1) 62 | mutex = threading.Lock() 63 | mutex.acquire() 64 | 65 | def _timer_work() -> NoReturn: 66 | """需要计时器到时之后释放锁""" 67 | mutex.release() 68 | 69 | def params(*args, **kwargs): 70 | """参数接收器""" 71 | 72 | def _worker_work(*args, **kwargs): 73 | """任务工作线程""" 74 | result.put(function(*args, **kwargs)) 75 | # 检查并尝试释放锁 76 | # pylint: disable=no-member 77 | if mutex.locked(): 78 | mutex.release() 79 | 80 | # 设置定时器 + 工作线程 81 | _timer = threading.Timer(limited, _timer_work) 82 | _worker = threading.Thread( 83 | target=_worker_work, args=args, kwargs=kwargs) 84 | _worker.setDaemon(True) 85 | _worker.start() 86 | _timer.start() 87 | 88 | # 尝试获取锁变量之后检查任务状态 89 | if mutex.acquire(): 90 | _timer.cancel() 91 | 92 | # 如果任务已经完成 - 返回结果 93 | if not result.empty(): 94 | return result.get() 95 | # 如果任务未完成 - 触发超时 96 | raise TimeoutError 97 | 98 | return result.get_nowait() 99 | 100 | return params 101 | return wrapper 102 | -------------------------------------------------------------------------------- /leaf/files/static/README.md: -------------------------------------------------------------------------------- 1 | ### 静态文件将会保存至此 -------------------------------------------------------------------------------- /leaf/files/uploads/README.md: -------------------------------------------------------------------------------- 1 | ### 用户上传的文件将会保存至此 -------------------------------------------------------------------------------- /leaf/payments/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 支付方式集合: 3 | wxpay - 符合 leaf.payment 抽象的微信支付模块 4 | alipay - 符合 leaf.payment 抽象的支付宝支付模块 5 | """ 6 | 7 | from . import wxpay 8 | from . import alipay 9 | from . import yandex 10 | -------------------------------------------------------------------------------- /leaf/payments/alipay/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 支付宝支付模块 3 | """ 4 | -------------------------------------------------------------------------------- /leaf/payments/wxpay/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 微信支付模块: 3 | payment - 主要支付模块 4 | signature - 微信支付签名工具 5 | settings - 微信支付相关设置 6 | methods - 微信支付方式 7 | const - 微信支付常量描述 8 | """ 9 | 10 | from . import payment 11 | from . import methods 12 | from . import signature 13 | 14 | from . import const 15 | from . import settings 16 | 17 | signature = signature.SignatureTool 18 | methods = methods.WXPaymentType 19 | payment = payment.WXPayment 20 | -------------------------------------------------------------------------------- /leaf/payments/wxpay/const.py: -------------------------------------------------------------------------------- 1 | """微信支付常量""" 2 | 3 | 4 | class WXPayAddress: 5 | """微信支付地址相关常量""" 6 | XMLTag = "xml" # 在 xml数据 外部包裹的xml标签 7 | Order = "https://api.mch.weixin.qq.com/pay/unifiedorder" 8 | Query = "https://api.mch.weixin.qq.com/pay/orderquery" 9 | Close = "https://api.mch.weixin.qq.com/pay/closeorder" 10 | Refund = "https://api.mch.weixin.qq.com/secapi/pay/refund" 11 | RefundQuery = "https://api.mch.weixin.qq.com/pay/refundquery" 12 | 13 | 14 | class WXPaySignature: 15 | """微信支付签名常量""" 16 | class Value: 17 | """固定值""" 18 | Type = "MD5" # 使用 MD5 方式签名 19 | Version = "1.0" # 固定值 1.0 20 | 21 | class Key: 22 | """固定键""" 23 | ApiKey = "key" # Apikey 24 | Version = "version" # 签名版本 25 | Sign = "sign" # 签名 26 | Type = "sign_type" # 签名类型 27 | Nonce = "nonce_str" # 随机字符 28 | 29 | 30 | class WXPayBasic: 31 | """微信支付基础常量""" 32 | AppID = "appid" # appid键 33 | Mch = "mch_id" # 商户号键 34 | OpenID = "openid" # OpenID 35 | TransactionID = "transaction_id" # 微信支付交易ID 36 | 37 | 38 | class WXPaymentNotify: 39 | """支付结果通知常量""" 40 | 41 | Status = "result_code" 42 | 43 | Fee = "cash_fee" # 支付金额 44 | FeeType = "cash_fee_type" # 支付币种 45 | 46 | class Error: 47 | """支付失败""" 48 | Code = "err_code" # 错误代码键 49 | Description = "err_code_des" # 错误描述 50 | 51 | 52 | class WXPayResponse: 53 | """微信支付响应常量""" 54 | Status = "return_code" # 通讯状态 55 | Message = "return_msg" # 通讯消息 56 | Success = "SUCCESS" # 通讯成功 57 | Fail = "FAIL" # 通讯失败 58 | Nothing = "OK" # 回复消息 59 | 60 | 61 | class WXPayOrder: 62 | """微信支付订单常量""" 63 | Callback = "notify_url" # 支付结果通知键 64 | 65 | class Type: 66 | """交易类型信息""" 67 | InApp = "APP" # APP 内部 68 | ScanQR = "NATIVE" # 原生支付 69 | JSAPI = "JSAPI" # JSAPI 支付 70 | 71 | class GoodsDetail: 72 | """详细信息内部键""" 73 | Name = "goods_name" # 商品名称 74 | Quantity = "quantity" # 商品数量 75 | Key = "goods_detail" # 外层包裹键 76 | 77 | class Info: 78 | """订单信息""" 79 | Describe = "body" # 订单描述 80 | Detail = "detail" # 详细信息 81 | Type = "trade_type" # 交易类型 - JSAPI... 82 | Attach = "attach" # 附加信息 83 | ProductID = "product_id" # 商品id 84 | 85 | class Time: 86 | """订单时间信息""" 87 | Start = "time_start" # 开始时间 88 | End = "time_expire" # 过期时间 89 | Format = "%Y%m%d%H%M%S" # 时间格式化模板 90 | Accuracy = 1000 # 时间生成器精度 91 | 92 | class Device: 93 | """支付设备信息""" 94 | IP = "spbill_create_ip" # 发起支付设备IP 95 | ID = "device_info" # 发起支付设备ID 96 | 97 | class Fee: 98 | """支付金额常量""" 99 | Amount = "total_fee" # 订单总金额 100 | Currency = "fee_type" # 订单支付币种 101 | 102 | class ID: 103 | """Id 相关常量""" 104 | In = "out_trade_no" # 商户的订单 Id 105 | Out = "transaction_id" # 微信的 trasaction_id 106 | Prepay = "prepay_id" # jsapi 调用返回的 prepay_id 107 | 108 | 109 | class WXPayRefund: 110 | """退款相关常量""" 111 | Offset = "offset" # 从第几个开始查询 112 | CurrentRefund = "refund_count" # 当前退款单数 113 | TotalRefunds = "total_refund_count" # 总共退款单数 114 | 115 | class Result: 116 | """退款结果信息""" 117 | @staticmethod 118 | def Status(no): 119 | """第几单的退款状态""" 120 | return "refund_status_" + str(no) 121 | 122 | @staticmethod 123 | def Account(no): 124 | """第几单的退款去向""" 125 | return "refund_recv_account_" + str(no) 126 | 127 | @staticmethod 128 | def Time(no): 129 | """第几单的退款成功时间""" 130 | return "refund_success_time_" + str(no) 131 | 132 | class ID: 133 | """ID 常量""" 134 | In = "out_refund_no" # 商户退款 ID 135 | Out = "refund_id" # 微信退款单号 136 | 137 | class Fee: 138 | """支付金额常量""" 139 | Amount = "refund_fee" # 退款金额 140 | Currency = "refund_fee_type" # 退款币种 141 | -------------------------------------------------------------------------------- /leaf/payments/wxpay/error.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guiqiqi/leaf/79e34f4b8fba8c6fd208b5a3049103dca2064ab5/leaf/payments/wxpay/error.json -------------------------------------------------------------------------------- /leaf/payments/wxpay/error.py: -------------------------------------------------------------------------------- 1 | """微信支付错误类定义""" 2 | 3 | from ...core.error import Error 4 | 5 | 6 | class WXPayError(Error): 7 | """微信支付错误""" 8 | code = 19000 9 | description = "微信支付错误父类" 10 | 11 | 12 | class WXPaySignatureError(WXPayError): 13 | """微信支付签名错误""" 14 | code = 19001 15 | description = "签名验证错误 - 警惕端口扫描" 16 | -------------------------------------------------------------------------------- /leaf/payments/wxpay/methods.py: -------------------------------------------------------------------------------- 1 | """微信支付方式""" 2 | 3 | from typing import NoReturn 4 | from . import const 5 | 6 | 7 | class WXPaymentType: 8 | """支付类型补充函数""" 9 | @staticmethod 10 | def jsapi(orderinfo: dict) -> NoReturn: 11 | """ 12 | 静态函数 - 对支付信息添加支付类型信息 - jaspi 13 | *orderinfo:dict - 支付信息字典 14 | """ 15 | orderinfo[const.WXPayOrder.Info.Type] = \ 16 | const.WXPayOrder.Type.JSAPI 17 | 18 | @staticmethod 19 | def native(orderinfo: dict) -> NoReturn: 20 | """ 21 | 静态函数 - 对支付信息添加支付类型信息 - native 22 | *orderinfo:dict - 支付信息字典 23 | """ 24 | orderinfo[const.WXPayOrder.Info.Type] = \ 25 | const.WXPayOrder.Type.ScanQR 26 | 27 | @staticmethod 28 | def inapp(orderinfo: dict) -> NoReturn: 29 | """ 30 | 静态函数 - 对支付信息添加支付类型信息 - inapp 31 | *orderinfo:dict - 支付信息字典 32 | """ 33 | orderinfo[const.WXPayOrder.Info.Type] = \ 34 | const.WXPayOrder.Type.InApp 35 | -------------------------------------------------------------------------------- /leaf/payments/wxpay/settings.py: -------------------------------------------------------------------------------- 1 | """微信支付设置量""" 2 | 3 | 4 | class Order: 5 | """订单相关设置""" 6 | DefaultCurrency = "CNY" # 默认货币为人民币 7 | MoreAttr = " ..." # 当多个商品时添加的说明 8 | PaymentDescription = "微信支付" # 支付方式的名称 9 | 10 | 11 | class NetworkAndEncrypt: 12 | """网络与加密相关设置""" 13 | NonceLength = 32 # 随机字符串不长于 32 位 14 | ExternalIPProvider = "https://api.ipify.org" # 外部IP地址提供 15 | 16 | 17 | class PaymentNotify: 18 | """支付结果通知中相关设置""" 19 | 20 | # 需要从结果中提取的业务相关键 21 | Keys = { 22 | "mch_id": "mchid", 23 | "is_subscribe": "subcribe", 24 | "bank_type": "bank", 25 | "time_end": "endtime", 26 | "transaction_id": "transaction", 27 | "attach": "attach" 28 | } 29 | -------------------------------------------------------------------------------- /leaf/payments/wxpay/signature.py: -------------------------------------------------------------------------------- 1 | """针对微信支付平台的加密功能""" 2 | 3 | import string 4 | import urllib.parse as urlparse 5 | from typing import NoReturn 6 | 7 | from ... import core 8 | from . import const 9 | from . import settings 10 | 11 | 12 | class SignatureTool: 13 | """微信支付加密模块""" 14 | 15 | def __repr__(self) -> str: 16 | """返回 repr 信息""" 17 | return "" 18 | 19 | def __init__(self, apikey: str): 20 | """ 21 | 加密模块初始化: 22 | apikey: 微信商户平台 API 密钥 23 | """ 24 | self.__apikey = apikey 25 | 26 | @staticmethod 27 | def _calculate(apikey: str, **paras) -> str: 28 | """ 29 | 根据微信公众平台支付加密标准对包进行签名: 30 | pay.weixin.qq.com/wiki/doc/api/external/*.php?chapter=4_3 31 | 0. 对参数字典中空的值进行处理 - 不参与运算 32 | 1. 对 paras 中传入的参数按照 ASCII 码的大小顺序进行排序 33 | 2. url化参数拼接 + apikey 34 | 3. 进行MD5签名 35 | """ 36 | # 首先过滤掉空参数 37 | __calculates = dict() 38 | for key, value in paras.items(): 39 | if str(value): 40 | __calculates[str(key)] = str(value) 41 | 42 | # 之后对参数进行排序并添加 apikey 43 | __sorted = sorted(__calculates.items(), key=lambda item: item[0]) 44 | __sorted.append((const.WXPaySignature.Key.ApiKey, apikey)) 45 | 46 | # 对参数进行 URL 化拼接并进行摘要运算 47 | clearstr = urlparse.urlencode(__sorted, safe=string.punctuation) 48 | sign = core.tools.encrypt.MD5(clearstr) 49 | 50 | return sign.upper() 51 | 52 | def do(self, unsigned: dict) -> NoReturn: 53 | """ 54 | 为参数字典进行签名 55 | 这里的参数字典不需要携带签名过程中的任何信息: 56 | 1. 随机字符串 - nonce_str 57 | 2. 签名 - sign 58 | 3. 签名类型 - sign_type 59 | 这些信息由签名工具自动生成, 调用者只需要关心业务相关信息 60 | """ 61 | # 生成一个随机数并添加 62 | randstr = core.tools.encrypt.random( 63 | settings.NetworkAndEncrypt.NonceLength) 64 | unsigned[const.WXPaySignature.Key.Nonce] = randstr 65 | 66 | # 添加固定值 - 这里 Version 会引起不同 API 调用错误 - 取消添加 67 | # unsigned[const.WXPaySignature.Key.Version] = \ 68 | # const.WXPaySignature.Value.Version 69 | 70 | unsigned[const.WXPaySignature.Key.Type] = \ 71 | const.WXPaySignature.Value.Type 72 | 73 | # 计算签名 74 | siganture = self._calculate(self.__apikey, **unsigned) 75 | unsigned[const.WXPaySignature.Key.Sign] = siganture 76 | 77 | def verify(self, **signed) -> bool: 78 | """ 79 | 对传入的数据包进行数据校验: 80 | 校验成功返回 True 失败返回 False 81 | 实际等同于重新计算一次签名值检查是否相同 82 | """ 83 | # 获取目标数据包的 Sign 值 84 | signature_signed = signed.pop(const.WXPaySignature.Key.Sign, None) 85 | if signature_signed is None: 86 | return False 87 | 88 | # 重新计算签名 89 | signature_unsigned = self._calculate(self.__apikey, **signed) 90 | 91 | # 判断是否相同 92 | return signature_signed == signature_unsigned 93 | -------------------------------------------------------------------------------- /leaf/payments/yandex/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guiqiqi/leaf/79e34f4b8fba8c6fd208b5a3049103dca2064ab5/leaf/payments/yandex/__init__.py -------------------------------------------------------------------------------- /leaf/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 插件支持模块: 3 | current - 当前的插件目录 4 | manager - 插件管理器 5 | settings - 插件相关设置 6 | """ 7 | 8 | from os import path as __path 9 | 10 | # 获取当前目录生成插件管理器 11 | from .manager import Manager 12 | from . import settings 13 | current = __path.split(__path.realpath(__file__))[0] 14 | -------------------------------------------------------------------------------- /leaf/plugins/accesstoken/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 使 leaf.weixin.accesstoken 模块获得的 3 | 微信公众平台 AccessToken 有接口对外公布 4 | """ 5 | 6 | from flask import abort 7 | 8 | from ... import api 9 | from ...core import modules 10 | from ...weixin.accesstoken import Patcher 11 | from ...core.abstract.plugin import Plugin 12 | 13 | plugin = Plugin( 14 | "AccessToken 导出插件", 15 | Plugin.nothing, Plugin.nothing, Plugin.nothing, 16 | author="桂小方", 17 | description="将微信模块获取到的 AccessToken 对外公布", 18 | version="beta - 0.2.1", 19 | date="2020-05-23" 20 | ) 21 | 22 | # 接口公开主机域 23 | AuthorizedHost = ["127.0.0.1"] 24 | 25 | # 注册插件主域 26 | modules.plugins.register(plugin, ["accesstoken"]) 27 | 28 | # 导出接口 29 | @plugin.route("/", methods=["GET"]) 30 | @api.wrapper.iplimit(AuthorizedHost) 31 | @api.wrapper.wrap("accesstoken") 32 | def accesstoken(): 33 | """获取当前已经缓存的 accesstoken""" 34 | patcher: Patcher = modules.weixin.accesstoken 35 | return patcher.get() 36 | -------------------------------------------------------------------------------- /leaf/plugins/error.py: -------------------------------------------------------------------------------- 1 | """插件运行的相关错误""" 2 | 3 | # from .. import modules 4 | # from ..core.error import Messenger 5 | from ..core.error import Error 6 | 7 | 8 | class PluginImportError(Error): 9 | """插件载入过程中出错""" 10 | code = 10101 11 | description = "插件载入时出错" 12 | 13 | 14 | class PluginNotFound(Error): 15 | """无法根据给定的 ID 寻找到插件""" 16 | code = 10102 17 | description = "没有找到对应的插件" 18 | 19 | 20 | class PluginInitError(Error): 21 | """不符合规范的插件""" 22 | code = 10103 23 | description = "插件 init 函数错误" 24 | 25 | 26 | class PluginRuntimeError(Error): 27 | """插件运行期错误""" 28 | code = 10104 29 | description = "插件运行期间出现错误" 30 | -------------------------------------------------------------------------------- /leaf/plugins/settings.py: -------------------------------------------------------------------------------- 1 | """插件的设置文件""" 2 | 3 | skipped = {"__pycache__"} # 需要跳过加载的目录列表 4 | -------------------------------------------------------------------------------- /leaf/rbac/__init__.py: -------------------------------------------------------------------------------- 1 | """用户用户组及认证相关 - 实现基于角色的用户权限控制""" 2 | 3 | from . import jwt 4 | from . import error 5 | from . import model 6 | from . import functions 7 | -------------------------------------------------------------------------------- /leaf/rbac/error.py: -------------------------------------------------------------------------------- 1 | """RBAC 相关的错误定义""" 2 | 3 | from ..core import error as _error 4 | 5 | 6 | class AuthenticationByIdFailed(_error.Error): 7 | """无法找到Id验证文档""" 8 | code = 10015 9 | description = "无法找到该用户的Id验证文档" 10 | 11 | 12 | class AuthenticationFailed(_error.Error): 13 | """创建/更新身份验证其余文档时密码验证未通过""" 14 | code = 10016 15 | description = "创建/更新身份验证-密码验证失败" 16 | 17 | 18 | class AuthenticationNotFound(_error.Error): 19 | """验证文档根据给定的索引未找到""" 20 | code = 10017 21 | description = "根据给定的文档索引无法查找到身份验证文档" 22 | 23 | 24 | class UserNotFound(_error.Error): 25 | """根据给定信息找不到用户""" 26 | code = 10018 27 | description = "根据给定信息找不到用户" 28 | 29 | 30 | class UserInitialized(_error.Error): 31 | """用户已经初始化完成""" 32 | code = 10019 33 | description = "用户初始化已经完成" 34 | 35 | 36 | class AccessPointNotFound(_error.Error): 37 | """访问点无法找到""" 38 | code = 10020 39 | description = "根据给定的信息找不到访问点文档" 40 | 41 | 42 | class GroupNotFound(_error.Error): 43 | """用户组文档无法找到""" 44 | code = 10021 45 | description = "根据给定的信息找不到用户组文档" 46 | 47 | 48 | class UserIndexValueBound(_error.Error): 49 | """用户索引信息已经被绑定""" 50 | code = 10022 51 | description = "您所给定的用户索引信息已经被绑定" 52 | 53 | 54 | class UserIndexTypeBound(_error.Error): 55 | """用户索引类型已经被绑定 - 当前策略不允许多绑定""" 56 | code = 10023 57 | description = "您所给定的用户索引类型已经被绑定 - 且当前策略不允许多绑定" 58 | 59 | 60 | class AuthenticationByIdCanNotDelete(_error.Error): 61 | """不允许删除根据 Id 创建的认证文档""" 62 | code = 10024 63 | description = "不能删除根据 用户Id 创建的认证文档" 64 | 65 | 66 | class InvalidToken(_error.Error): 67 | """Token 格式错误""" 68 | code = 12112 69 | description = "JWT Token 格式错误" 70 | 71 | 72 | class InvalidHeder(_error.Error): 73 | """Token 头部格式错误""" 74 | code = 12111 75 | description = "JWT Token 头部格式错误/不支持" 76 | 77 | 78 | class SignatureError(_error.Error): 79 | """签名计算失败""" 80 | code = 12113 81 | description = "JWT Token 的签名计算错误 - 检查secret是否与算法匹配" 82 | 83 | 84 | class SignatureNotValid(_error.Error): 85 | """签名验证失败""" 86 | code = 12114 87 | description = "JWT Token 签名验证错误" 88 | 89 | 90 | class TimeExpired(_error.Error): 91 | """过期错误""" 92 | code = 12115 93 | description = "JWT Token 过期" 94 | 95 | 96 | class TokenNotFound(_error.Error): 97 | """在 HTTP Header 信息中没有发现 JWT Token 信息""" 98 | code = 12116 99 | description = "在 HTTP Header 信息中没有发现 JWT Token 信息" 100 | 101 | 102 | class AuthenticationError(_error.Error): 103 | """身份验证错误""" 104 | code = 13001 105 | description = "身份验证错误" 106 | 107 | 108 | class AuthenticationDisabled(AuthenticationError): 109 | """该身份验证方式被禁用""" 110 | code = 13002 111 | description = "该身份验证方式被禁用" 112 | 113 | 114 | class InvalidExceptionAccessPonint(_error.Error): 115 | """前端传入的例外用户组无法识别/用户ID错误""" 116 | code = 13003 117 | description = "传入的例外用户组无法被识别/用户ID错误" 118 | 119 | 120 | class AccessPointNameConflicting(_error.Error): 121 | """访问点名称冲突""" 122 | code = 13004 123 | description = "访问点名称冲突" 124 | 125 | 126 | class UndefinedUserIndex(_error.Error): 127 | """未定义的用户索引类型""" 128 | code = 13005 129 | description = "未定义的用户索引类型" 130 | -------------------------------------------------------------------------------- /leaf/rbac/functions/__init__.py: -------------------------------------------------------------------------------- 1 | """CRUD 使用到的函数集合""" 2 | 3 | from . import auth 4 | from . import user 5 | from . import group 6 | from . import accesspoint 7 | -------------------------------------------------------------------------------- /leaf/rbac/functions/accesspoint.py: -------------------------------------------------------------------------------- 1 | """访问点所需要的函数集合""" 2 | 3 | from typing import List 4 | 5 | from .. import error 6 | from ..model import AccessPoint 7 | 8 | 9 | class Create: 10 | """创建静态函数集合""" 11 | 12 | 13 | class Retrieve: 14 | """查找静态函数集合""" 15 | 16 | @staticmethod 17 | def byname(name: str) -> AccessPoint: 18 | """根据名称查找访问点""" 19 | # pylint: disable=no-member 20 | found: List[AccessPoint] = AccessPoint.objects(pointname=name) 21 | if not found: 22 | raise error.AccessPointNotFound(name) 23 | return found[0] 24 | 25 | class Update: 26 | """更新静态函数集合""" 27 | 28 | 29 | class Delete: 30 | """删除静态函数集合""" 31 | -------------------------------------------------------------------------------- /leaf/rbac/functions/group.py: -------------------------------------------------------------------------------- 1 | """用户组操作的相关函数集合""" 2 | 3 | from typing import List 4 | 5 | from bson import ObjectId 6 | 7 | from .. import error 8 | from ..model.group import Group 9 | 10 | 11 | class Create: 12 | """创建用户组的静态函数集合""" 13 | 14 | 15 | class Retrieve: 16 | """查询用户组的静态函数集合""" 17 | 18 | @staticmethod 19 | def byid(groupid: ObjectId) -> Group: 20 | """通过用户组 ID 查找用户组记录""" 21 | # pylint: disable=no-member 22 | found: List[Group] = Group.objects(id=groupid) 23 | if not found: 24 | raise error.GroupNotFound(str(groupid)) 25 | return found[0] 26 | 27 | 28 | class Update: 29 | """更新/编辑用户组的静态函数集合""" 30 | 31 | 32 | class Delete: 33 | """删除用户组的静态函数结合""" 34 | -------------------------------------------------------------------------------- /leaf/rbac/functions/user.py: -------------------------------------------------------------------------------- 1 | """用户相关的数据库操作函数集合""" 2 | 3 | from typing import List, NoReturn 4 | 5 | import mongoengine 6 | from bson import ObjectId 7 | 8 | from . import auth 9 | from . import group 10 | from .. import error 11 | from .. import settings 12 | from ..model import User 13 | from ..model import UserIndex 14 | 15 | 16 | class Create: 17 | """创建用户静态函数集合""" 18 | 19 | @staticmethod 20 | def index(userid: ObjectId, userindex: UserIndex) -> List[UserIndex]: 21 | """ 22 | 该函数将为用户新增一种索引类型: 23 | 1. 首先会检查指定的索引值是否被其他用户绑定过 24 | 2. 尝试插入检查是否发生 unique 错误 25 | """ 26 | # 检查是否其他用户已经绑定过 27 | typeid, value = userindex.typeid, userindex.value 28 | if Retrieve.byindex(typeid, value): 29 | raise error.UserIndexValueBound(typeid + " - " + value) 30 | 31 | try: 32 | user: User = Retrieve.byid(userid) 33 | user.indexs.append(userindex) 34 | except mongoengine.NotUniqueError as _error: 35 | raise error.UserIndexTypeBound(typeid) 36 | else: 37 | user.indexs.save() 38 | return user.indexs 39 | 40 | @staticmethod 41 | def group(userid: ObjectId, groupid: ObjectId) -> User: 42 | """ 43 | 向组中添加用户: 44 | 向用户组的用户记录中添加 45 | 向用户的组记录中添加 46 | """ 47 | userid = ObjectId(userid) 48 | groupid = ObjectId(groupid) 49 | user = Retrieve.byid(userid) 50 | ugroup = group.Retrieve.byid(groupid) 51 | if not ugroup in user.groups: 52 | user.groups.append(ugroup) 53 | 54 | if not userid in ugroup.users: 55 | ugroup.users.append(userid) 56 | ugroup.save() 57 | 58 | return user.save() 59 | 60 | 61 | class Retrieve: 62 | """查询用户静态函数集合""" 63 | 64 | @staticmethod 65 | def byindex(typeid: str, value: str) -> List[User]: 66 | """通过 Index 用户索引文档查询用户记录""" 67 | # pylint: disable=no-member 68 | return User.objects(indexs__value=value, 69 | indexs__typeid=typeid) 70 | 71 | @staticmethod 72 | def byid(userid: ObjectId) -> User: 73 | """通过用户 Id 查询用户文档""" 74 | # pylint: disable=no-member 75 | users = User.objects(id=userid) 76 | if not users: 77 | raise error.UserNotFound(str(userid)) 78 | return users[0] 79 | 80 | @staticmethod 81 | def initialized(userid: ObjectId) -> bool: 82 | """查询用户是否被初始化""" 83 | user: User = Retrieve.byid(userid) 84 | if not user.indexs: 85 | return False 86 | return True 87 | 88 | 89 | class Update: 90 | """更新用户静态函数集合""" 91 | 92 | @staticmethod 93 | def inituser(userid: ObjectId) -> User: 94 | """ 95 | 该函数将为用户创建ID索引 96 | 创建一个用户的接口调用顺序如下: 97 | 1. 首先调用创建用户接口 - View 层处理 98 | 2. 调用为用户设置密码接口 - auth.Create.withuserid 99 | 3. 为用户设置文档ID索引 - 该接口 100 | """ 101 | try: 102 | user: User = Retrieve.byid(userid) 103 | 104 | # 为用户创建Id索引 105 | typeid, description = settings.User.Indexs.Id 106 | idindex = UserIndex( 107 | typeid=typeid, value=str(userid), 108 | description=description) 109 | user.indexs.append(idindex) 110 | 111 | except mongoengine.NotUniqueError as _error: 112 | raise error.UserInitialized(str(userid)) 113 | except IndexError as _error: 114 | raise error.UserNotFound(str(userid)) 115 | else: 116 | return user.save() 117 | 118 | 119 | class Delete: 120 | """删除用户静态函数集合""" 121 | 122 | @staticmethod 123 | def index(userid: ObjectId, typeid: str) -> NoReturn: 124 | """ 125 | 删除用户的某一种 Index 126 | 同时要删除该 Index 对应的 Auth 文档 127 | """ 128 | 129 | user: User = Retrieve.byid(userid) 130 | try: 131 | index = user.indexs.get(typeid=typeid) 132 | except mongoengine.errors.DoesNotExist as _error: 133 | return user.indexs 134 | 135 | user.indexs.remove(index) 136 | user.indexs.save() 137 | 138 | # 查找并删除指定的 Auth 文档 139 | # pylint: disable=no-member 140 | try: 141 | authdoc = auth.Retrieve.byindex(index.value) 142 | except error.AuthenticationNotFound as _error: 143 | pass 144 | else: 145 | authdoc.delete() 146 | 147 | return user.indexs 148 | 149 | @staticmethod 150 | def group(userid: ObjectId, groupid: ObjectId) -> NoReturn: 151 | """ 152 | 将用户从某个用户组中移除: 153 | 将用户从用户组中移除 154 | 将组中的用户记录删除 155 | """ 156 | userid = ObjectId(userid) 157 | groupid = ObjectId(groupid) 158 | user = Retrieve.byid(userid) 159 | ugroup = group.Retrieve.byid(groupid) 160 | if ugroup in user.groups: 161 | user.groups.remove(ugroup) 162 | user.save() 163 | 164 | if userid in ugroup.users: 165 | ugroup.users.remove(userid) 166 | ugroup.save() 167 | -------------------------------------------------------------------------------- /leaf/rbac/jwt/__init__.py: -------------------------------------------------------------------------------- 1 | """JWT Token 包""" 2 | 3 | from . import const 4 | from . import settings 5 | 6 | from .token import Token 7 | from .verify import Verify 8 | -------------------------------------------------------------------------------- /leaf/rbac/jwt/const.py: -------------------------------------------------------------------------------- 1 | """JWT Token常量相关设置""" 2 | 3 | 4 | class Header: 5 | """头部相关设置""" 6 | Type = "typ" # 类型 7 | Algorithm = "alg" # 加密算法 8 | 9 | @staticmethod 10 | def make(algorithm: str) -> dict: 11 | """生成头部""" 12 | return { 13 | Header.Type: "JWT", 14 | Header.Algorithm: algorithm 15 | } 16 | 17 | 18 | class Payload: 19 | """载荷相关常量""" 20 | Issuer = "iss" # 签发者 21 | Expiration = "exp" # 过期时间 22 | IssuedAt = "iat" # 签发时间 23 | Audience = "aud" # 颁发对象 24 | -------------------------------------------------------------------------------- /leaf/rbac/jwt/settings.py: -------------------------------------------------------------------------------- 1 | """JWT Token相关设置""" 2 | 3 | from ...core.tools import encrypt 4 | 5 | 6 | class Signature: 7 | """签名相关设置""" 8 | Algorithm = ("HS256", encrypt.HMAC_SHA256) # 默认签名算法 9 | ValidPeriod = 3600 # 默认 Token 有效期 10 | 11 | 12 | class Payload: 13 | """自定义载荷部分设置""" 14 | Permission = "permission" # 签发时的用户权限 15 | -------------------------------------------------------------------------------- /leaf/rbac/jwt/token.py: -------------------------------------------------------------------------------- 1 | """JWT Token 类""" 2 | 3 | from typing import Tuple, Optional, Callable, NoReturn, Dict 4 | 5 | from ...core.tools import web 6 | from ...core.tools import time 7 | from ...core.tools import encrypt 8 | 9 | from . import const 10 | from . import settings 11 | 12 | 13 | class Token: 14 | """Token 计算与生成器""" 15 | 16 | def __init__(self, secret: str): 17 | """ 18 | Token 类构造函数: 19 | parts: 部分令牌 20 | secret: 签名密钥 21 | algorithm: 在设置头部时指定算法 22 | """ 23 | self.__parts = list() 24 | self.__secret: str = secret 25 | self.__algorithm: Callable[[bytes, bytes], str] = None 26 | 27 | def header(self, algorithm: Optional[Tuple[str, Callable]] 28 | = settings.Signature.Algorithm) -> NoReturn: 29 | """计算头部字符串""" 30 | header = const.Header.make(algorithm[0]) 31 | self.__algorithm = algorithm[1] 32 | text = web.JSONcreater(header) 33 | self.__parts.append(encrypt.base64encode_url(text.encode()).decode()) 34 | 35 | def payload(self, issuer: str, audience: str, 36 | period: int = settings.Signature.ValidPeriod, 37 | other: Optional[Dict[str, str]] = None) -> NoReturn: 38 | """计算载荷部分的值""" 39 | now = time.now() 40 | payload = { 41 | const.Payload.Issuer: issuer, 42 | const.Payload.Audience: audience, 43 | const.Payload.IssuedAt: now, 44 | const.Payload.Expiration: now + period 45 | } 46 | if not other is None: 47 | payload.update(other) 48 | text = web.JSONcreater(payload) 49 | self.__parts.append(encrypt.base64encode_url(text.encode()).decode()) 50 | 51 | def issue(self) -> str: 52 | """签名之后返回可用的Token""" 53 | content = '.'.join(self.__parts) 54 | signature = self.__algorithm(content.encode(), self.__secret.encode()) 55 | return content + '.' + encrypt.base64encode_url(signature).decode() 56 | -------------------------------------------------------------------------------- /leaf/rbac/jwt/verify.py: -------------------------------------------------------------------------------- 1 | """JWT Token 验证模块""" 2 | 3 | from typing import NoReturn, Callable, Optional, Tuple 4 | 5 | from . import const 6 | from . import settings 7 | from .. import error 8 | from ...core.tools import web 9 | from ...core.tools import time 10 | from ...core.tools import encrypt 11 | 12 | 13 | class Verify: 14 | """JWT Token 验证类""" 15 | 16 | def __init__(self, token: str): 17 | """ 18 | JWT Token验证类: 19 | 分割并检查Token格式是否正确 20 | 还原被encode的信息 21 | algorithm - 使用的加密算法 22 | content - 无签名部分的token 23 | """ 24 | try: 25 | header, payload, signature = token.split('.') 26 | except ValueError as _error: 27 | raise error.InvalidToken(token) 28 | 29 | self.__algorithm: Callable[[bytes, bytes], str] = None 30 | self.__header: str = encrypt.base64decode_url(header.encode()).decode() 31 | self.__payload: str = encrypt.base64decode_url(payload.encode()).decode() 32 | self.__content: str = header + '.' + payload 33 | self.__signature: bytes = encrypt.base64decode_url(signature.encode()) 34 | 35 | def header(self, algorithm: Optional[Tuple[str, Callable]] 36 | = settings.Signature.Algorithm) -> dict: 37 | """验证头部信息是否正确""" 38 | header = web.JSONparser(self.__header) 39 | target = const.Header.make(algorithm[0]) 40 | self.__algorithm = algorithm[1] 41 | 42 | if target != header: 43 | raise error.InvalidHeder(str(header)) 44 | 45 | return header 46 | 47 | def payload(self) -> dict: 48 | """验证是否过期""" 49 | payload = web.JSONparser(self.__payload) 50 | now = time.now() 51 | expired = payload.get(const.Payload.Expiration) 52 | if now > expired: 53 | raise error.TimeExpired("过期在: " + time.timestr(expired)) 54 | 55 | return payload 56 | 57 | def signature(self, secret: str) -> NoReturn: 58 | """验证签名是否正确""" 59 | target = self.__algorithm(self.__content.encode(), secret.encode()) 60 | if target != self.__signature: 61 | raise error.SignatureNotValid(self.__signature) 62 | -------------------------------------------------------------------------------- /leaf/rbac/model/__init__.py: -------------------------------------------------------------------------------- 1 | """用户, 组, 及相关认证数据库模型""" 2 | 3 | from .group import Group 4 | from .user import User 5 | from .user import UserIndex 6 | from .auth import Authentication 7 | from .accesspoint import AccessPoint 8 | -------------------------------------------------------------------------------- /leaf/rbac/model/accesspoint.py: -------------------------------------------------------------------------------- 1 | """访问权限点的数据库模型建立""" 2 | 3 | import mongoengine 4 | 5 | from .user import User 6 | 7 | 8 | class AccessPoint(mongoengine.Document): 9 | """ 10 | 权限访问点的数据库模型: 11 | pointname: 权限点名称(e.g. leaf.plugins.wxtoken.get) 12 | required: 需求的最小权限值 13 | strict: 是否要求仅仅指定权限值的用户可访问 14 | description: 当前权限点的描述 15 | exceptions: 针对某些用户的特例 16 | """ 17 | 18 | pointname = mongoengine.StringField(primary_key=True) 19 | required = mongoengine.IntField(required=True) 20 | strict = mongoengine.BooleanField(default=False) 21 | description = mongoengine.StringField(default=str) 22 | exceptions = mongoengine.ListField( 23 | field=mongoengine.LazyReferenceField(User), default=list) 24 | -------------------------------------------------------------------------------- /leaf/rbac/model/auth.py: -------------------------------------------------------------------------------- 1 | """认证记录数据库模型""" 2 | 3 | import mongoengine 4 | 5 | from .user import User 6 | 7 | 8 | class Authentication(mongoengine.Document): 9 | """ 10 | 身份验证文档模型: 11 | user: 被建立登陆方式的用户id 12 | index: 用户索引(账户名-主键) 13 | salt: 用户密码盐 14 | token: 用户密码加盐之后的哈希值 15 | status: 验证方式是否可用 16 | description: 身份验证方式描述 17 | """ 18 | 19 | index = mongoengine.StringField(primary_key=True) 20 | user = mongoengine.LazyReferenceField( 21 | User, reverse_delete_rule=mongoengine.CASCADE) 22 | salt = mongoengine.StringField(required=True) 23 | token = mongoengine.StringField(required=True) 24 | status = mongoengine.BooleanField(default=True) 25 | description = mongoengine.StringField(default=str) 26 | -------------------------------------------------------------------------------- /leaf/rbac/model/group.py: -------------------------------------------------------------------------------- 1 | """用户组数据库模型""" 2 | 3 | import mongoengine 4 | 5 | 6 | class Group(mongoengine.Document): 7 | """ 8 | 用户组模型: 9 | name: 用户组的名称 10 | description: 用户组的描述信息 11 | permission: 用户组的权限值 12 | users: 用户组中包含的用户 13 | extensions: 扩展信息存储 14 | """ 15 | 16 | name = mongoengine.StringField(required=True, default=str) 17 | description = mongoengine.StringField(default=str) 18 | permission = mongoengine.IntField(required=True, default=int) 19 | users = mongoengine.ListField(mongoengine.ObjectIdField()) 20 | extensions = mongoengine.MapField(field=mongoengine.StringField()) 21 | -------------------------------------------------------------------------------- /leaf/rbac/model/user.py: -------------------------------------------------------------------------------- 1 | """用户数据模型""" 2 | 3 | import mongoengine 4 | 5 | from .. import settings 6 | from ...core.tools import time 7 | from .group import Group 8 | 9 | 10 | class UserIndex(mongoengine.EmbeddedDocument): 11 | """ 12 | 用户索引内嵌文档模型: 13 | typeid: 类型id 同一种索引方式相同 14 | value: 用户索引值 15 | description: 用户索引描述 16 | extension: 扩展描述字典 17 | """ 18 | 19 | typeid = mongoengine.StringField(required=True) 20 | value = mongoengine.StringField(required=True) 21 | description = mongoengine.StringField(default=str) 22 | extension = mongoengine.DictField() 23 | 24 | 25 | class User(mongoengine.Document): 26 | """ 27 | 用户数据库模型: 28 | created: 创建时间 默认为utc时间 29 | disabled: 用户是否已被禁用 30 | groups: 用户被分配到的用户组信息 31 | indexs: 用户的索引信息列表 32 | informations: 用户的个人信息 33 | """ 34 | 35 | created = mongoengine.IntField(default=time.now) 36 | disabled = mongoengine.BooleanField(default=False) 37 | groups = mongoengine.ListField(mongoengine.ReferenceField( 38 | Group, reverse_delete_rule=mongoengine.PULL), default=list) 39 | indexs = mongoengine.EmbeddedDocumentListField(UserIndex, default=list) 40 | avatar = mongoengine.ImageField( 41 | size=settings.User.AvatarSize, 42 | thumbnail_size=settings.User.AvatarThumbnailSize) 43 | informations = mongoengine.DictField() 44 | -------------------------------------------------------------------------------- /leaf/rbac/settings.py: -------------------------------------------------------------------------------- 1 | """用户, 组, 认证相关的设置文件""" 2 | 3 | from collections import namedtuple 4 | from ..core.algorithm import StaticDict 5 | 6 | 7 | Index = namedtuple("Index", ("typeid", "description")) 8 | 9 | 10 | class User: 11 | """用户相关的设置文件""" 12 | # 是否允许同一种索引方式存在多个 13 | # 例如同账户绑定了多个不同的微信号 14 | # 默认情况不被允许 15 | # 选项被禁用 - 2020.2.19 16 | # AllowMultiAccountBinding = False 17 | 18 | # 头像与缩略图大小 - (width, height, 是否强制缩放) 19 | AvatarSize = (80, 80, True) 20 | AvatarThumbnailSize = (50, 50, True) 21 | AvatarType = {"jpg", "jpeg", "png", "gif"} 22 | 23 | Indexs = StaticDict({ 24 | # 根据 Id 的索引方式请勿删除 - 会导致错误 25 | "Id": Index("1B4E705F3305F7FB", "通过用户ID索引"), # 通过用户id索引 26 | 27 | # 下面的索引方式可以拓展 28 | "Mail": Index("EAC366AD5FEA1B28", "通过邮件索引"), # 通过邮件索引 29 | "Name": Index("0C6B4A2B8AAEDDBC", "通过用户名索引"), # 通过用户名索引 30 | "Phone": Index("5E4BC1ABDDAACA4A", "通过手机号索引") # 通过手机号索引 31 | }) 32 | 33 | 34 | class Authentication: 35 | """认证相关的设置文件""" 36 | 37 | class Security: 38 | """安全策略设置""" 39 | SaltLength = 128 # 密钥盐的长度(bits) 40 | SaltCahce = 128 # 盐数据库查询的缓存数量 41 | PasswordHashCycle = 4 # 对密码进行迭代哈希的次数 42 | 43 | class Description: 44 | """描述字符串""" 45 | Id = "通过用户ID验证" 46 | Mail = "通过邮相验证" 47 | Name = "通过用户名验证" 48 | Phone = "通过电话验证" 49 | -------------------------------------------------------------------------------- /leaf/selling/__init__.py: -------------------------------------------------------------------------------- 1 | """SKU/SPU/销售相关""" 2 | 3 | from . import order 4 | from . import error 5 | from . import commodity 6 | from . import settings 7 | -------------------------------------------------------------------------------- /leaf/selling/commodity/__init__.py: -------------------------------------------------------------------------------- 1 | """SPU与产品管理相关""" 2 | 3 | from .stock import Stock 4 | from .product import Product 5 | from .product import ProductParameter 6 | from .generator import StocksGenerator 7 | -------------------------------------------------------------------------------- /leaf/selling/commodity/generator.py: -------------------------------------------------------------------------------- 1 | """使用树算法根据产品生成商品信息""" 2 | 3 | from queue import Queue 4 | 5 | from typing import List 6 | from typing import NoReturn 7 | 8 | from .stock import Stock 9 | from .product import Product 10 | from .product import ProductParameter 11 | 12 | from ...core.algorithm import tree 13 | 14 | 15 | class StocksGenerator: 16 | """ 17 | 使用树算法根据传入的产品信息生成商品并设置 18 | """ 19 | 20 | def __init__(self, product: Product) -> NoReturn: 21 | """ 22 | 类初始化函数: 23 | product: 要使用进行生成的产品信息 24 | """ 25 | self.__product = product 26 | self.__stocks: List[Stock] = list() 27 | 28 | @property 29 | def goods(self) -> List[Stock]: 30 | """返回内存中的所有商品列表""" 31 | return self.__stocks 32 | 33 | def setall(self, price: float, currency: str, inventory: int) -> NoReturn: 34 | """ 35 | 对所有的商品设置统一信息: 36 | price: 商品价格 37 | currency: 商品价格货币 38 | inventory: 商品库存 39 | """ 40 | for good in self.__stocks: 41 | good.price = price 42 | good.currency = currency 43 | good.inventory = inventory 44 | 45 | @staticmethod 46 | def _generate(product: Product) -> tree.Tree: 47 | """ 48 | 使用 BFS 算法生成一棵产品信息树: 49 | 0. 生成 None 的根节点, 将根节点加入任务队列 50 | 1. 将当前遍历属性的 index 加入任务节点 51 | 2. 获取任务节点以及需要添加的信息 52 | 3. goto 0 until tasks.qsize() == 0 53 | """ 54 | index, root, height = 0, tree.Node(None), len(product.parameters) 55 | tasks = Queue() 56 | tasks.put((root, index)) 57 | 58 | # 开始 BFS 的主循环 59 | while tasks.qsize(): 60 | node, index = tasks.get() 61 | option: ProductParameter = product.parameters[index] 62 | children = [tree.Node(option.name, selection) 63 | for selection in option.options] 64 | node.adds(children) 65 | if index == height - 1: 66 | continue 67 | for task in children: 68 | tasks.put((task, index + 1)) 69 | 70 | return tree.Tree(root) 71 | 72 | @staticmethod 73 | def _traverse(infotree: tree.Tree) -> List[dict]: 74 | """遍历所有的叶子节点路径, 将节点信息取出之后update""" 75 | goods = list() 76 | 77 | for leaf, path in infotree.leaves(): 78 | attributes = dict() 79 | # 加入叶子结点的信息 80 | attributes.update({leaf.tag: leaf.value}) 81 | 82 | for node in path: 83 | attributes.update({node.tag: node.value}) 84 | 85 | # 将根节点的无意义数据 pop 出去 86 | attributes.pop(None) 87 | goods.append(attributes) 88 | 89 | return goods 90 | 91 | def clean(self) -> int: 92 | """ 93 | 对现有的产品信息中的旧商品信息进行清理 94 | 这个操作会清除数据库中所有产品生成的商品记录 95 | """ 96 | # pylint: disable=no-member 97 | return Stock.objects(product=self.__product).delete() 98 | 99 | def save(self) -> int: 100 | """将内存中的所有商品对象入库""" 101 | rows = len(self.__stocks) 102 | for good in self.__stocks: 103 | good.save() 104 | 105 | self.__stocks = [] 106 | return rows 107 | 108 | def calculate(self) -> NoReturn: 109 | """生成与遍历树 - 内存中生成所有的商品对象""" 110 | ctree = self._generate(self.__product) 111 | attributes = self._traverse(ctree) 112 | 113 | for attribute in attributes: 114 | good = Stock(individual=False, 115 | product=self.__product, 116 | name=self.__product.name, 117 | attributes=attribute, 118 | description=self.__product.description, 119 | addition=self.__product.addition, 120 | tags=self.__product.tags) 121 | self.__stocks.append(good) 122 | -------------------------------------------------------------------------------- /leaf/selling/commodity/product.py: -------------------------------------------------------------------------------- 1 | """产品类型数据库模型""" 2 | 3 | import mongoengine 4 | 5 | 6 | class ProductParameter(mongoengine.EmbeddedDocument): 7 | """ 8 | 产品参数内嵌文档: 9 | name: 选项名称 10 | options: 可选项值列表 11 | """ 12 | 13 | name = mongoengine.StringField() 14 | options = mongoengine.ListField() 15 | 16 | 17 | class Product(mongoengine.Document): 18 | """ 19 | 产品类数据模型: 20 | name: 产品名称 21 | description: 产品描述 22 | addtion: 产品额外描述 23 | tags: 产品标签列表 24 | onsale: 产品是否上架 25 | parameters: 产品可选项目列表 26 | extension: 扩展数据存储 27 | """ 28 | 29 | name = mongoengine.StringField() 30 | description = mongoengine.StringField() 31 | addition = mongoengine.StringField() 32 | tags = mongoengine.ListField(field=mongoengine.StringField()) 33 | parameters = mongoengine.EmbeddedDocumentListField(ProductParameter) 34 | onsale = mongoengine.BooleanField(default=True) 35 | extensions = mongoengine.DictField(default=dict) 36 | -------------------------------------------------------------------------------- /leaf/selling/commodity/stock.py: -------------------------------------------------------------------------------- 1 | """商品数据模型""" 2 | 3 | import mongoengine 4 | 5 | from .product import Product 6 | from .. import settings 7 | 8 | 9 | class Stock(mongoengine.Document): 10 | """ 11 | 商品数据模型: 12 | individual: 是否是独立的商品(非产品子类) 13 | attributes: 由产品生成时记录该商品的选项信息 14 | product: 父级产品类(当individual为False时) 15 | name: 商品名称(当individual为True时) 16 | description: 商品描述(当individual为True时) 17 | addition: 商品附加信息(当individual为True时) 18 | tags: 商品标签列表(当individual为True时) 19 | price: 商品价格 20 | currency: 商品货币单位 ISO-4217 21 | inventory: 商品库存数量 22 | onsale: 商品是否上架 23 | extensions: 扩展数据存储 24 | """ 25 | 26 | individual = mongoengine.BooleanField(required=True, default=False) 27 | product = mongoengine.ReferenceField( 28 | Product, reverse_delete_rule=mongoengine.CASCADE) 29 | name = mongoengine.StringField() 30 | attributes = mongoengine.DictField() 31 | description = mongoengine.StringField() 32 | addition = mongoengine.StringField() 33 | tags = mongoengine.ListField(mongoengine.StringField()) 34 | price = mongoengine.FloatField() 35 | currency = mongoengine.StringField(default=settings.General.DefaultCurrency) 36 | inventory = mongoengine.IntField() 37 | onsale = mongoengine.BooleanField(default=True) 38 | extensions = mongoengine.DictField(default=dict) 39 | -------------------------------------------------------------------------------- /leaf/selling/error.py: -------------------------------------------------------------------------------- 1 | """销售相关错误定义""" 2 | 3 | from ..core import error as _error 4 | 5 | 6 | class ProductNotFound(_error.Error): 7 | """根据给定信息找不到对应的产品""" 8 | code = 10025 9 | description = "根据给定信息找不到对应的产品" 10 | 11 | 12 | class ProductParameterNotFound(_error.Error): 13 | """找不到对应的产品参数信息""" 14 | code = 10026 15 | description = "找不到对应的产品参数信息" 16 | 17 | 18 | class ProductParameterConflicting(_error.Error): 19 | """产品参数信息冲突""" 20 | code = 10027 21 | description = "产品参数信息发现重复" 22 | 23 | 24 | class StockNotFound(_error.Error): 25 | """根据给定信息找不到对应商品""" 26 | code = 10028 27 | description = "根据给定信息找不到对应的商品" 28 | 29 | 30 | class InvalidCurrency(_error.Error): 31 | """非允许的交易货币类型""" 32 | code = 10029 33 | description = "不允许给定的货币类型进行交易" 34 | 35 | 36 | class EmptyOrder(_error.Error): 37 | """试图创建一个空订单信息""" 38 | code = 10030 39 | description = "不能用空商品列表创建订单" 40 | 41 | 42 | class DiffrentCurrencies(_error.Error): 43 | """创建订单的商品货币类型不统一""" 44 | code = 10031 45 | description = "商品货币类型不统一" 46 | 47 | 48 | class DiscontinueStock(_error.Error): 49 | """给定的商品已经停售""" 50 | code = 10032 51 | description = "试图创建订单的商品已经停售" 52 | 53 | 54 | class InsufficientInventory(_error.Error): 55 | """库存不足""" 56 | code = 10033 57 | description = "所选商品库存不足" 58 | 59 | 60 | class InvalidPaymentCallback(_error.Error): 61 | """支付平台通知到的订单信息找不到了 - 这可是很严重的错误""" 62 | code = 10034 63 | description = "无法找到支付平台通知到的订单" 64 | -------------------------------------------------------------------------------- /leaf/selling/functions/__init__.py: -------------------------------------------------------------------------------- 1 | """产品销售相关的 RBAC 业务层函数""" 2 | 3 | from . import product 4 | -------------------------------------------------------------------------------- /leaf/selling/functions/product.py: -------------------------------------------------------------------------------- 1 | """产品相关的 RBAC 函数""" 2 | 3 | from typing import List, Dict 4 | from collections import defaultdict 5 | 6 | import cacheout 7 | import mongoengine 8 | from bson import ObjectId 9 | 10 | from .. import error 11 | from .. import settings 12 | from ..commodity import Product 13 | from ..commodity import ProductParameter 14 | 15 | 16 | class Create: 17 | """Product 创建类""" 18 | 19 | @staticmethod 20 | def parameter(productid: ObjectId, name: str, 21 | options: List[str]) -> List[ProductParameter]: 22 | """给产品增加一个参数选项""" 23 | product = Retrieve.byid(productid) 24 | parameter = ProductParameter(name=name, options=options) 25 | 26 | try: 27 | product.parameters.get(name=name) 28 | except mongoengine.errors.DoesNotExist as _error: 29 | product.parameters.append(parameter) 30 | else: 31 | raise error.ProductParameterConflicting(name) 32 | 33 | product.parameters.save() 34 | return product.parameters 35 | 36 | 37 | class Update: 38 | """产品信息更新类""" 39 | 40 | @staticmethod 41 | def onsale(productid: ObjectId, status: bool) -> Product: 42 | """更新产品在售信息""" 43 | product = Retrieve.byid(productid) 44 | product.onsale = status 45 | return product.save() 46 | 47 | 48 | class Retrieve: 49 | """Product 信息查询类""" 50 | 51 | tags_cache = cacheout.Cache(ttl=settings.Product.TagsCacheTime) 52 | 53 | @staticmethod 54 | def byid(productid: ObjectId) -> Product: 55 | """根据产品 Id 查询产品""" 56 | # pylint: disable=no-member 57 | product: List[Product] = Product.objects(id=productid) 58 | if not product: 59 | raise error.ProductNotFound(productid) 60 | return product[0] 61 | 62 | @staticmethod 63 | def byname(name: str) -> List[Product]: 64 | """根据名称查找产品""" 65 | # pylint: disable=no-member 66 | return Product.objects(name__icontains=name) 67 | 68 | @staticmethod 69 | def bytags(tags: List[str]) -> List[Product]: 70 | """根据产品标签查找产品""" 71 | # 首先对标签进行头尾空格处理, 小写化处理 72 | tags = [tag.strip().lower() for tag in tags] 73 | 74 | #pylint: disable=no-member 75 | queryset: List[Product] = Product.objects(tags=tags[0]) 76 | for tag in tags[1::]: 77 | queryset = queryset.filter(tags=tag) 78 | return queryset 79 | 80 | @staticmethod 81 | @tags_cache.memoize() 82 | def tags() -> Dict[str, int]: 83 | """查询全部的 Tags""" 84 | tags = defaultdict(int) 85 | # pylint: disable=no-member 86 | for product in Product.objects: 87 | for tag in product.tags: 88 | tags[tag] += 1 89 | 90 | return tags 91 | 92 | 93 | class Delete: 94 | """删除相关操作函数集合""" 95 | 96 | @staticmethod 97 | def parameter(productid: ObjectId, name: str) -> List[ProductParameter]: 98 | """删除产品的的一个指定参数""" 99 | product = Retrieve.byid(productid) 100 | try: 101 | parameter: ProductParameter = product.parameters.get(name=name) 102 | except mongoengine.errors.DoesNotExist as _error: 103 | raise error.ProductParameterNotFound(name) 104 | 105 | product.parameters.remove(parameter) 106 | product.parameters.save() 107 | return product.parameters 108 | -------------------------------------------------------------------------------- /leaf/selling/functions/stock.py: -------------------------------------------------------------------------------- 1 | """商品的相关CRUD函数""" 2 | 3 | from typing import List 4 | from bson import ObjectId 5 | 6 | from .. import error 7 | from ..commodity import Stock 8 | 9 | class Retrieve: 10 | """查询相关函数""" 11 | 12 | @staticmethod 13 | def byid(goodid: ObjectId) -> Stock: 14 | """根据商品ID查询商品""" 15 | # pylint: disable=no-member 16 | goods: List[Stock] = Stock.objects(id=goodid) 17 | if not goods: 18 | raise error.StockNotFound(goodid) 19 | return goods[0] 20 | 21 | @staticmethod 22 | def byname(name: str, individual: bool = False) -> List[Stock]: 23 | """根据名称查找商品""" 24 | # pylint: disable=no-member 25 | if individual: 26 | return Stock.objects(name__icontains=name, individual=True) 27 | return Stock.objects(name__icontains=name) 28 | 29 | @staticmethod 30 | def bytags(tags: List[str], individual: bool = False) -> List[Stock]: 31 | """根据标签查找商品""" 32 | # 首先对标签进行头尾空格处理, 小写化处理 33 | tags = [tag.strip().lower() for tag in tags] 34 | 35 | #pylint: disable=no-member 36 | if individual: 37 | queryset: List[Stock] = Stock.objects(tags=tags[0], individual=True) 38 | else: 39 | queryset: List[Stock] = Stock.objects(tags=tags[0]) 40 | 41 | for tag in tags[1::]: 42 | queryset = queryset.filter(tags=tag) 43 | return queryset 44 | 45 | 46 | class Delete: 47 | """删除相关函数""" 48 | 49 | 50 | class Create: 51 | """创建相关函数""" 52 | 53 | 54 | class Update: 55 | """更新相关函数""" 56 | 57 | @staticmethod 58 | def onsale(goodid: str, status: bool) -> Stock: 59 | """更新商品在售信息""" 60 | good = Retrieve.byid(goodid) 61 | good.onsale = status 62 | return good.save() 63 | -------------------------------------------------------------------------------- /leaf/selling/order/__init__.py: -------------------------------------------------------------------------------- 1 | """订单相关的模型与文件""" 2 | 3 | from . import events 4 | from . import status 5 | from . import manager 6 | from . import settings 7 | from .order import Order 8 | -------------------------------------------------------------------------------- /leaf/selling/order/events.py: -------------------------------------------------------------------------------- 1 | """所有的订单状态事件""" 2 | # pylint: disable=arguments-differ 3 | 4 | from typing import NoReturn, Dict 5 | 6 | from ...core.tools import encrypt 7 | from ...core.abstract import payment 8 | from ...core.algorithm import fsm 9 | 10 | from .settings import Events as settings 11 | 12 | 13 | class Confirm(fsm.Event): 14 | """用户确认订单""" 15 | description: str = settings.Description.Confirm 16 | 17 | 18 | class UserClose(fsm.Event): 19 | """用户主动关闭订单""" 20 | description: str = settings.Description.UserClose 21 | 22 | def action(self, reason: str) -> NoReturn: 23 | """记录用户主动关闭订单的原因""" 24 | self.append(settings.ExtraInformation.CloseReason, reason) 25 | 26 | 27 | class Paying(fsm.Event): 28 | """用户正在付款""" 29 | description: str = settings.Description.Paying 30 | 31 | def action(self, payments: Dict[payment.AbstractPayment, float]) -> NoReturn: 32 | """用户支付的支付方式以及金额进行保存""" 33 | 34 | items: Dict[str, float] = dict() # 支付的详情信息存储 35 | for method, fee in payments.items(): 36 | items[str(method)] = fee 37 | 38 | self.append(settings.ExtraInformation.Payments, items) 39 | 40 | 41 | class PayingSuccess(fsm.Event): 42 | """支付平台通知支付成功""" 43 | description: str = settings.Description.PayingSuccess 44 | 45 | def action(self, payid: str, fee: float) -> NoReturn: 46 | """记录支付单的id""" 47 | self.append(settings.ExtraInformation.PayId, payid) 48 | self.append(settings.ExtraInformation.PayFee, fee) 49 | 50 | 51 | class PayingFailed(fsm.Event): 52 | """支付平台通知支付失败""" 53 | description: str = settings.Description.PayingFailed 54 | 55 | def action(self, payid: str, reason: str) -> NoReturn: 56 | """记录支付失败的原因""" 57 | self.append(settings.ExtraInformation.PayId, payid) 58 | self.append(settings.ExtraInformation.PayFailReason, reason) 59 | 60 | 61 | class OrderRetry(fsm.Event): 62 | """订单重试""" 63 | description: str = settings.Description.OrderRetry 64 | 65 | 66 | class OrderTimedOut(fsm.Event): 67 | """订单超时, 系统关闭订单""" 68 | description: str = settings.Description.OrderTimedOut 69 | 70 | 71 | class Shipped(fsm.Event): 72 | """商品已经交付快递""" 73 | description: str = settings.Description.Shipped 74 | 75 | def action(self, shipping) -> NoReturn: 76 | """记录物流单号""" 77 | self.append(settings.ExtraInformation.ShipInfo, shipping) 78 | 79 | 80 | class Delieverd(fsm.Event): 81 | """收到物流平台消息商品已经被送达""" 82 | description: str = settings.Description.Delieverd 83 | 84 | 85 | class Recieved(fsm.Event): 86 | """用户主动确认收货""" 87 | description: str = settings.Description.Recieved 88 | 89 | 90 | class RecieveTimingExcced(fsm.Event): 91 | """超时系统自动确认收货""" 92 | description: str = settings.Description.RecieveTimingExcced 93 | 94 | 95 | class RequestRefund(fsm.Event): 96 | """用户申请退款""" 97 | description: str = settings.Description.RequestRefund 98 | 99 | def action(self, reason: str) -> NoReturn: 100 | """记录用户申请退款的原因并下发申请单号""" 101 | refundid = encrypt.uuid() 102 | self.append(settings.ExtraInformation.RefundReason, reason) 103 | self.append(settings.ExtraInformation.RefundNumber, refundid) 104 | 105 | 106 | class RefundDenied(fsm.Event): 107 | """退款审核未通过""" 108 | description: str = settings.Description.RefundDenied 109 | 110 | def action(self, reason: str) -> NoReturn: 111 | """记录退款审核未通过的原因""" 112 | self.append(settings.ExtraInformation.RefundDenyReason, reason) 113 | 114 | 115 | class RefundApproved(fsm.Event): 116 | """退款审核已经通过, 等待支付平台处理退款""" 117 | description: str = settings.Description.RefundApproved 118 | 119 | 120 | class RefundSuccess(fsm.Event): 121 | """退款已经完成""" 122 | description: str = settings.Description.RefundSuccess 123 | 124 | 125 | class RefundFailed(fsm.Event): 126 | """退款失败""" 127 | description: str = settings.Description.RefundFailed 128 | 129 | def action(self, reason: str) -> NoReturn: 130 | """记录退款失败信息""" 131 | self.append(settings.ExtraInformation.RefundFailReason, reason) 132 | -------------------------------------------------------------------------------- /leaf/selling/order/manager.py: -------------------------------------------------------------------------------- 1 | """ 2 | 使用有限状态机算法 3 | 通过已经创建的事件和状态 4 | 对订单状态进行自动的调度 5 | """ 6 | # pylint: disable=arguments-differ 7 | 8 | from typing import NoReturn 9 | from ...core.tools import web 10 | from ...core.algorithm import fsm 11 | 12 | from . import events 13 | from . import status 14 | from . import settings 15 | 16 | # 状态转移表 17 | _TransferTable = ( 18 | (status.Created, events.Confirm, status.Confirmed), 19 | (status.Created, events.UserClose, status.Closed), 20 | (status.Created, events.OrderTimedOut, status.Closed), 21 | (status.Confirmed, events.Paying, status.Paying), 22 | (status.Confirmed, events.UserClose, status.Closed), 23 | (status.Confirmed, events.OrderTimedOut, status.Closed), 24 | (status.Paying, events.PayingSuccess, status.Paid), 25 | (status.Paying, events.PayingFailed, status.PayFailed), 26 | (status.PayFailed, events.OrderTimedOut, status.Closed), 27 | (status.PayFailed, events.OrderRetry, status.Created), 28 | (status.Paid, events.Shipped, status.Shipping), 29 | (status.Shipping, events.Delieverd, status.Delieverd), 30 | (status.Delieverd, events.Recieved, status.Completed), 31 | (status.Delieverd, events.RecieveTimingExcced, status.Completed), 32 | (status.Completed, events.RequestRefund, status.RefundReviewing), 33 | (status.RefundReviewing, events.RefundApproved, status.Refunding), 34 | (status.RefundReviewing, events.RefundDenied, status.Completed), 35 | (status.Refunding, events.RefundSuccess, status.Closed), 36 | (status.Refunding, events.RefundFailed, status.Completed) 37 | ) 38 | 39 | 40 | class StatusManager(fsm.Machine): 41 | """创建一个订单状态管理器""" 42 | 43 | def __init__(self, orderid: str) -> NoReturn: 44 | """ 45 | 订单状态管理器构造函数: 46 | 0. 根据订单id对管理器命名 47 | 1. 初始化状态转移对应表 48 | 2. 初始化进入状态 49 | """ 50 | super().__init__(str(orderid)) 51 | 52 | # 初始化状态转移对应表 53 | for record in _TransferTable: 54 | self.add(*record) 55 | 56 | def start(self) -> NoReturn: 57 | """开始从订单创建开始""" 58 | super().start(status.Created) 59 | 60 | def json(self) -> str: 61 | """将当前状态信息导出为 JSON 字串""" 62 | if not self.current is None: 63 | current = { 64 | settings.Status.Key.Code: self.current.code, 65 | settings.Status.Key.Description: self.current.description, 66 | settings.Status.Key.Extra: self.current.extra 67 | } # 当前状态导出字典 68 | else: 69 | current = None 70 | 71 | mapping = map(lambda event: { 72 | settings.Events.Key.Time: event.time, 73 | settings.Events.Key.OperationCode: event.opcode, 74 | settings.Events.Key.Extra: event.extra, 75 | settings.Events.Key.Description: event.description 76 | }, self.events) # 事件记录器导出为字典 77 | 78 | return web.JSONcreater({ 79 | settings.Manager.Key.Name: self.name, 80 | settings.Manager.Key.CurrentStat: current, 81 | settings.Manager.Key.EventsRecorder: list(mapping) 82 | }) 83 | -------------------------------------------------------------------------------- /leaf/selling/order/order.py: -------------------------------------------------------------------------------- 1 | """订单类数据库模型""" 2 | 3 | import pickle 4 | from typing import List, NoReturn 5 | import mongoengine 6 | 7 | from . import events 8 | from . import manager as _man 9 | from .. import error 10 | from .. import settings 11 | from ..commodity import stock 12 | 13 | from ...core import modules 14 | from ...core import schedule 15 | from ...core.algorithm import fsm 16 | 17 | 18 | class Order(mongoengine.Document): 19 | """ 20 | 订单类数据库模型: 21 | amount: 订单总金额 - float 22 | goods: 购买商品列表 - List[CacheedRef] 23 | status: 当前状态JSON字串 - str 24 | instance: 订单状态管理器的 pickle 对象 - bytes 25 | """ 26 | amount = mongoengine.FloatField(min_value=0, required=True) 27 | currency = mongoengine.StringField( 28 | default=settings.General.DefaultCurrency) 29 | 30 | goods = mongoengine.ListField( 31 | mongoengine.CachedReferenceField(stock.Stock)) 32 | instance = mongoengine.BinaryField(default=bytes) 33 | 34 | @staticmethod 35 | def create(goods: List[stock.Stock], 36 | timeout: int = settings.Order.OrderTimeout): 37 | """根据给定的商品列表创建一个订单类实例后入库""" 38 | 39 | # 检查列表是否为空 40 | if not goods: 41 | raise error.EmptyOrder(str(goods)) 42 | 43 | # 检查商品是否都在售 44 | for good in goods: 45 | if not good.onsale: 46 | raise error.DiscontinueStock(good.id) 47 | 48 | # 检查商品设置的货币类型是否相同 49 | currencies = [good.currency for good in goods] 50 | if len(set(currencies)) != 1: 51 | raise error.DiffrentCurrencies(str(currencies)) 52 | 53 | # 检查商品是否还有库存 54 | inventories = [good.inventory for good in goods] 55 | if not min(inventories): 56 | _iid = goods[inventories.index(0)].id 57 | raise error.InsufficientInventory(_iid) 58 | 59 | # 目标对象库存减少一 60 | for good in goods: 61 | good.inventory -= 1 62 | good.save() 63 | 64 | currency = currencies.pop() 65 | amount = sum([good.price for good in goods]) 66 | order = Order(amount=amount, currency=currency, goods=goods) 67 | order.save() 68 | 69 | # pylint: disable=no-member 70 | instance = _man.StatusManager(order.id) 71 | instance.start() 72 | order.instance = pickle.dumps(instance) 73 | order.save() 74 | 75 | # 设置订单超时定时器 76 | def __cancel_order(): 77 | """取消当前订单""" 78 | cancellation = events.OrderTimedOut() 79 | order.manager.handle(cancellation) 80 | timer = schedule.Worker(__cancel_order, timeout, 1, False) 81 | schedule_manager: schedule.Manager = modules.schedules 82 | schedule_manager.start(timer) 83 | return order 84 | 85 | @property 86 | def manager(self): 87 | """返回当前订单类的 manager 实例代理""" 88 | if not self.instance: 89 | return None 90 | man: _man.StatusManager = pickle.loads(self.instance) 91 | return ManagerProxy(self, man) 92 | 93 | 94 | class ManagerProxy: 95 | """ 96 | 代理Manager的访问 97 | add 函数不代理 - 实例化后的状态机不应该改变状态图 98 | """ 99 | 100 | def __init__(self, db: Order, instance: _man.StatusManager): 101 | """实例化代理对象""" 102 | self._db, self._instance = db, instance 103 | 104 | def json(self): 105 | """代理 json 提取操作""" 106 | return self._instance.json() 107 | 108 | @property 109 | def name(self) -> str: 110 | """返回名称""" 111 | return self._instance.name 112 | 113 | @property 114 | def current(self) -> fsm.State: 115 | """返回当前状态""" 116 | return self._instance.current 117 | 118 | @property 119 | def events(self) -> List[fsm.Event]: 120 | """代理 events 操作""" 121 | return self._instance.events 122 | 123 | def stop(self) -> NoReturn: 124 | """代理 stop 操作""" 125 | self._instance.stop() 126 | self._db.instance = pickle.dumps(self._instance) 127 | self._db.save() 128 | 129 | def start(self) -> NoReturn: 130 | """代理 start 操作""" 131 | self._instance.start() 132 | self._db.instance = pickle.dumps(self._instance) 133 | self._db.save() 134 | 135 | def handle(self, event: fsm.Event) -> NoReturn: 136 | """代理事件处理操作""" 137 | self._instance.handle(event) 138 | self._db.instance = pickle.dumps(self._instance) 139 | self._db.save() 140 | -------------------------------------------------------------------------------- /leaf/selling/order/settings.py: -------------------------------------------------------------------------------- 1 | """订单相关设置""" 2 | 3 | 4 | class Events: 5 | """事件相关设置""" 6 | 7 | class Key: 8 | """导出键设置""" 9 | OperationCode = "code" # 操作码 10 | Time = "time" # 发生时间 11 | Description = "description" # 描述 12 | Extra = "extra" # 额外信息 13 | 14 | class ExtraInformation: 15 | """额外信息描述键""" 16 | CloseReason = "订单关闭原因" 17 | Payments = "支付详情" 18 | PayId = "支付单号" 19 | PayFee = "实际支付金额" 20 | PayFailReason = "支付失败原因" 21 | ShipInfo = "物流信息" 22 | RefundReason = "退款原因" 23 | RefundNumber = "退款单号" 24 | RefundDenyReason = "退款申请未通过原因" 25 | RefundFailReason = "退款在支付平台处理失败原因" 26 | 27 | class Description: 28 | """描述设置""" 29 | Confirm = "用户已经确认了订单, 等待支付" 30 | UserClose = "用户主动关闭订单" 31 | Paying = "用户正在付款, 等待支付结果" 32 | PayingSuccess = "用户已支付成功" 33 | PayingFailed = "用户支付失败" 34 | OrderRetry = "支付失败, 转入重试" 35 | OrderTimedOut = "订单超时, 系统关闭订单" 36 | Shipped = "商品已经交付物流" 37 | Delieverd = "商品已经送达" 38 | Recieved = "用户确认收货" 39 | RecieveTimingExcced = "超时系统自动确认收货" 40 | RequestRefund = "用户申请退款" 41 | RefundDenied = "用户退款申请已拒绝" 42 | RefundApproved = "用户退款申请已通过, 等待支付平台处理退款" 43 | RefundSuccess = "支付平台退款完成" 44 | RefundFailed = "支付平台退款失败" 45 | 46 | 47 | class Status: 48 | """订单状态相关设置""" 49 | 50 | class Key: 51 | """导出键设置""" 52 | Code = "code" # 状态码 53 | Description = "description" # 说明 54 | Extra = "extra" # 额外信息 55 | 56 | class ExtraInfomation: 57 | """额外信息补充描述""" 58 | Reason = "原因" 59 | Operator = "操作者" 60 | OperateTime = "操作时间" 61 | ShipDetails = "物流详细信息" 62 | 63 | class Description: 64 | """订单状态描述""" 65 | Created = "订单创建" 66 | Confirmed = "订单已经被确认, 等待用户支付" 67 | Paying = "用户正在支付, 等待支付结果" 68 | Paid = "用户支付成功, 等待发货" 69 | PayFailed = "用户支付失败, 尝试重新支付" 70 | Shipping = "订单已经交付物流运送" 71 | Delieverd = "订单物流已经完成" 72 | RefundReviewing = "退款单正在审核" 73 | Refunding = "退款已经交付支付平台处理" 74 | Completed = "订单已经完成" 75 | Closed = "订单已关闭" 76 | 77 | 78 | class Manager: 79 | """订单状态管理器相关设置""" 80 | 81 | class Key: 82 | """导出键设置""" 83 | Name = "orderid" # 状态机名称 - 订单号 84 | CurrentStat = "current" # 当前状态 85 | EventsRecorder = "events" # 事件记录器 86 | -------------------------------------------------------------------------------- /leaf/selling/order/status.py: -------------------------------------------------------------------------------- 1 | """订单的所有状态""" 2 | # pylint: disable=signature-differs 3 | # # pylint: disable=arguments-differ 4 | 5 | from ...core.algorithm import fsm 6 | 7 | from . import events 8 | from .settings import Status as settings 9 | 10 | # 状态 - 订单已经创建 11 | Created = fsm.State(0, settings.Description.Created) 12 | Created.add(events.Confirm) 13 | Created.add(events.UserClose) 14 | 15 | # 状态 - 用户开始支付 16 | Confirmed = fsm.State(1, settings.Description.Confirmed) 17 | Confirmed.add(events.Paying) 18 | 19 | 20 | class _Paying(fsm.State): 21 | """状态 - 用户正在支付""" 22 | 23 | def enter(self, payments: fsm.Event): 24 | """进入正在支付状态 - 保存支付详情""" 25 | self.extra = payments.extra 26 | 27 | 28 | Paying = _Paying(2, settings.Description.Paying) 29 | Paying.add(events.PayingSuccess) 30 | Paying.add(events.PayingFailed) 31 | 32 | 33 | class _Paid(fsm.State): 34 | """状态 - 订单支付成功""" 35 | 36 | def enter(self, payid: fsm.Event): 37 | """进入支付成功状态 - 保存支付单详情""" 38 | self.extra = payid.extra 39 | 40 | 41 | Paid = _Paid(3, settings.Description.Paid) 42 | Paid.add(events.Shipped) 43 | 44 | 45 | class _PayFailed(fsm.State): 46 | """状态 - 用户支付失败""" 47 | 48 | def enter(self, reason: fsm.Event): 49 | """进入支付失败状态 - 保存支付失败原因""" 50 | self.extra = reason.extra 51 | 52 | 53 | PayFailed = _PayFailed(4, settings.Description.PayFailed) 54 | PayFailed.add(events.OrderRetry) 55 | PayFailed.add(events.OrderTimedOut) 56 | 57 | 58 | class _Shipping(fsm.State): 59 | """状态 - 订单正在运送""" 60 | 61 | def enter(self, reason: fsm.Event): 62 | """进入运送状态保存运单信息并创建详细信息列表""" 63 | self.extra = reason.extra 64 | self.extra[settings.ExtraInfomation.ShipDetails] = list() 65 | 66 | 67 | Shipping = _Shipping(5, settings.Description.Shipping) 68 | Shipping.add(events.Delieverd) 69 | 70 | 71 | # 状态 - 订单已签收 72 | Delieverd = fsm.State(6, settings.Description.Delieverd) 73 | Delieverd.add(events.RecieveTimingExcced) 74 | Delieverd.add(events.Recieved) 75 | 76 | 77 | # 状态 - 订单完成 78 | Completed = fsm.State(7, settings.Description.Completed) 79 | Completed.add(events.RequestRefund) 80 | 81 | 82 | class _RefundReviewing(fsm.State): 83 | """状态 - 退款正在审核""" 84 | 85 | def enter(self, reason: fsm.Event): 86 | """ 87 | 进入退款审核状态时需要采集: 88 | 0. 退款申请原因 89 | 1. 退款申请事件生成的退款申请码 90 | """ 91 | self.extra = reason.extra 92 | 93 | 94 | RefundReviewing = fsm.State(8, settings.Description.RefundReviewing) 95 | RefundReviewing.add(events.RefundApproved) 96 | RefundReviewing.add(events.RefundDenied) 97 | 98 | 99 | Refunding = fsm.State(9, settings.Description.Refunding) 100 | Refunding.add(events.RefundSuccess) 101 | Refunding.add(events.RefundFailed) 102 | 103 | 104 | class _Closed(fsm.State): 105 | """状态 - 订单关闭""" 106 | 107 | def enter(self, reason: fsm.Event): 108 | """ 109 | 进入订单关闭时检测进入原因: 110 | 0. 如果是用户主动关闭则记录关闭原因 111 | 1. 如果是发生了订单超时事件则记录为订单超时 112 | 2. 如果是退款则记录订单已经完成退款 113 | """ 114 | self.extra[settings.ExtraInfomation.OperateTime] = reason.time 115 | self.extra[settings.ExtraInfomation.Reason] = reason.description 116 | if reason == events.UserClose: 117 | self.extra = reason.extra 118 | 119 | 120 | Closed = _Closed(10, settings.Description.Closed) 121 | -------------------------------------------------------------------------------- /leaf/selling/settings.py: -------------------------------------------------------------------------------- 1 | """产品与销售相关设置""" 2 | 3 | from ..core.schedule import MINUTE 4 | from ..core.schedule import HOUR 5 | 6 | 7 | class General: 8 | """与交易相关的全局设置""" 9 | 10 | DefaultCurrency = "CNY" # 默认的货币类型 11 | AllowCurrency = { 12 | "CNY", "USD", "RUB" 13 | } # 允许设置的价格单位 14 | 15 | 16 | class Order: 17 | """订单相关的设置项目""" 18 | 19 | OrderTimeout = 30 * MINUTE # 默认订单超时时间为30min 20 | 21 | 22 | class Product: 23 | """产品相关的设置""" 24 | 25 | TagsCacheTime = 3 * HOUR # 查询全部标签的缓存有效时间 26 | -------------------------------------------------------------------------------- /leaf/views/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Leaf 框架 view 层蓝图管理: 3 | rbac - 用户权限控制框架蓝图 4 | wxpay - 微信支付回调函数蓝图 5 | weixin - 微信公众平台回调函数蓝图 6 | plugins - 插件管理及功能支持蓝图 7 | """ 8 | 9 | from . import rbac 10 | from . import wxpay 11 | from . import weixin 12 | from . import plugins 13 | -------------------------------------------------------------------------------- /leaf/views/commodity/__init__.py: -------------------------------------------------------------------------------- 1 | """关于商品与销售相关的视图函数""" 2 | 3 | # pylint: disable=wrong-import-position 4 | 5 | commodity = __import__("flask").Blueprint("commodity", __name__) 6 | 7 | from . import product 8 | from . import stock 9 | -------------------------------------------------------------------------------- /leaf/views/order.py: -------------------------------------------------------------------------------- 1 | """订单相关的接口与事件绑定""" 2 | 3 | import logging 4 | from typing import NoReturn 5 | 6 | from flask import Blueprint 7 | 8 | from ..core import modules 9 | from ..core import events as _events 10 | from ..core.error import Error as _Error 11 | 12 | from ..selling import error 13 | from ..selling.order import Order 14 | from ..selling.order import events 15 | 16 | order = Blueprint("order", __name__) 17 | 18 | # 注册支付成功与失败及退款提醒事件 19 | # 位置参数列表 - openid, transaction id, trade_out_no, cash_fee 20 | logger = logging.getLogger("leaf.selling.order") 21 | 22 | 23 | def _paysucess_handler(_openid: str, tactid: str, orderid: str, amount: float) -> NoReturn: 24 | """支付成功通知处理""" 25 | # pylint: disable=no-member 26 | _order = Order.objects(id=orderid) 27 | 28 | try: 29 | # 被通知到的订单不存在 - 记录错误(严重错误) 30 | if not _order: 31 | raise error.InvalidPaymentCallback( 32 | orderid + ' - ' + str(amount)) 33 | _order: Order = _order.pop() 34 | 35 | # 生成一个支付成功的有限状态机事件 36 | paid = events.PayingSuccess() 37 | paid.action(tactid, amount) 38 | _order.manager.handle(paid) 39 | 40 | except _Error as _error: 41 | logger.exception(_error) 42 | 43 | 44 | def _payfail_handler(_openid: str, tactid: str, orderid: str, 45 | amount: float, reason: str) -> NoReturn: 46 | """支付失败通知处理""" 47 | # pylint: disable=no-member 48 | _order = Order.objects(id=orderid) 49 | 50 | try: 51 | # 被通知到的订单不存在 - 记录错误(严重错误) 52 | if not _order: 53 | raise error.InvalidPaymentCallback( 54 | orderid + ' - ' + str(amount)) 55 | _order: Order = _order.pop() 56 | 57 | # 生成一个支付失败的有限状态机事件 58 | payfailed = events.PayingFailed() 59 | payfailed.action(tactid, reason) 60 | _order.manager.handle(payfailed) 61 | 62 | except _Error as _error: 63 | logger.exception(_error) 64 | 65 | 66 | _wxpay_paysuccess: _events.Event = modules.events.event( 67 | "leaf.payments.wxpay.notify.pay.success") 68 | _wxpay_payfail: _events.Event = modules.events.event( 69 | "leaf.payments.wxpay.notify.pay.fail") 70 | 71 | _wxpay_paysuccess.hook(_paysucess_handler) 72 | _wxpay_payfail.hook(_payfail_handler) 73 | # _wxpay_refund.hook() # 暂时不启用 74 | -------------------------------------------------------------------------------- /leaf/views/plugins.py: -------------------------------------------------------------------------------- 1 | """对插件路由的支持""" 2 | 3 | from typing import Iterable 4 | 5 | from flask import g 6 | from flask import abort 7 | from flask import request 8 | from flask import Response 9 | from flask import Blueprint 10 | 11 | from ..core import modules 12 | from ..core.abstract.plugin import Plugin 13 | from ..plugins import Manager 14 | 15 | # 生成蓝图 16 | plugins = Blueprint("plugins", __name__) 17 | 18 | # 所有 HTTP 方法 19 | __ALL_METHODS = [ 20 | 'GET', 'HEAD', 'POST', 'PUT', 21 | 'DELETE', 'CONNECT', 'OPTIONS', 22 | 'TRACE', 'PATCH' 23 | ] 24 | 25 | 26 | def __para_converter(paras: dict, order: Iterable) -> tuple: 27 | """ 28 | 根据给定的参数字典和顺序列表返回有序参数序列: 29 | __para_converter({ 30 | "second": 2, "first": 1 31 | }, ("first", "second")) 32 | 33 | *注意: 当参数在 paras 中未找到时会以 None 代替 - 而不是引发 KeyError 34 | """ 35 | result = tuple(paras.get(key) for key in order) 36 | return result 37 | 38 | 39 | # 定义插件的 after_request 函数支持 40 | @plugins.after_request 41 | def after_request(response: Response) -> Response: 42 | """ 43 | 执行在 forward 函数中获取到的插件 after_request 函数 44 | """ 45 | if g.success: 46 | g.plugin_func_after_request(response) 47 | return response 48 | 49 | # 定义插件的函数支持 50 | @plugins.route("/", methods=__ALL_METHODS) 51 | def forward(token: str) -> object: 52 | """ 53 | 从给定的 Token 中寻找需要运行的插件 54 | 并将当前的的控制流移交给对应插件 55 | 获取到数据之后进行返回 56 | 57 | *访问的 url 示例: 58 | init.blog/plugins/accesstoken.get 59 | """ 60 | 61 | g.success = False 62 | 63 | # 获取插件的访问主域以及 url 64 | domain, *urls = token.split(".") 65 | url: str = '/'.join(urls) 66 | 67 | # 当访问主页时经过上述处理会得到空字符串 68 | url = '/' if not url else url 69 | 70 | # 获取插件管理器根据 domain 寻找插件 71 | manager: Manager = modules.plugins 72 | plugin: Plugin = manager.domain(domain) 73 | if plugin is None: 74 | return abort(404) 75 | 76 | # 运行插件的 before_request 函数并设置 after_request 77 | plugin.get_before_request()() 78 | g.success = True 79 | g.plugin_func_after_request = plugin.get_after_request() 80 | 81 | # 获取视图函数以及其需要的参数 82 | handler, paramaters = plugin.find(url, request.method) 83 | order = plugin.paramaters(handler) 84 | paramaters = __para_converter(paramaters, order) 85 | 86 | # 将控制流交给插件 87 | return handler(*paramaters) 88 | -------------------------------------------------------------------------------- /leaf/views/rbac/__init__.py: -------------------------------------------------------------------------------- 1 | """关于 RBAC 相关的视图函数""" 2 | 3 | # pylint: disable=wrong-import-position 4 | 5 | rbac = __import__("flask").Blueprint("rbac", __name__) 6 | 7 | from . import user 8 | from . import jwt 9 | from . import group 10 | from . import auth 11 | from . import accesspoint 12 | -------------------------------------------------------------------------------- /leaf/views/rbac/accesspoint.py: -------------------------------------------------------------------------------- 1 | """AccessPoint 模型视图函数""" 2 | 3 | from typing import List, Set 4 | 5 | from flask import request 6 | from bson import ObjectId 7 | from mongoengine import NotUniqueError 8 | 9 | from . import rbac 10 | from ...api import wrapper 11 | from ...core.tools import web 12 | 13 | from ...rbac import error 14 | from ...rbac.model import AccessPoint 15 | from ...rbac.functions import user 16 | from ...rbac.functions import accesspoint as funcs 17 | 18 | 19 | @rbac.route("/accesspoints/", methods=["GET"]) 20 | @wrapper.require("leaf.views.rbac.accesspoint.query") 21 | @wrapper.wrap("accesspoint") 22 | def query_accesspoint_byname(pointname: str) -> AccessPoint: 23 | """根据指定的名称查找相关的访问点信息""" 24 | point: AccessPoint = funcs.Retrieve.byname(pointname) 25 | return point 26 | 27 | 28 | @rbac.route("/accesspoints", methods=["GET"]) 29 | @wrapper.require("leaf.views.rbac.accesspoint.get") 30 | @wrapper.wrap("accesspoints") 31 | def getall_accesspoints() -> List[AccessPoint]: 32 | """返回所有的访问点信息""" 33 | # pylint: disable=no-member 34 | return list(AccessPoint.objects) 35 | 36 | 37 | @rbac.route("/accesspoints/", methods=["DELETE"]) 38 | @wrapper.require("leaf.views.rbac.accesspoint.delete") 39 | @wrapper.wrap("status") 40 | def delete_accesspoint(pointname: str) -> bool: 41 | """删除某一个访问点信息""" 42 | point: AccessPoint = funcs.Retrieve.byname(pointname) 43 | point.delete() 44 | return True 45 | 46 | 47 | @rbac.route("/accesspoints", methods=["POST"]) 48 | @wrapper.require("leaf.views.rbac.accesspoint.create") 49 | @wrapper.wrap("accesspoint") 50 | def create_accesspoint() -> AccessPoint: 51 | """创建一个访问点信息""" 52 | pointname: str = request.form.get("pointname", type=str, default='') 53 | required: int = request.form.get("required", type=int, default=0) 54 | strict: bool = bool(request.form.get("strict", type=int, default=0)) 55 | description: str = request.form.get("description", type=str, default='') 56 | 57 | try: 58 | point: AccessPoint = AccessPoint(pointname=pointname, required=required, 59 | strict=strict, description=description) 60 | return point.save() 61 | except NotUniqueError as _error: 62 | raise error.AccessPointNameConflicting(pointname) 63 | 64 | 65 | @rbac.route("/accesspoints/", methods=["PUT"]) 66 | @wrapper.require("leaf.views.rbac.accesspoint.update") 67 | @wrapper.wrap("accesspoint") 68 | def update_accesspoint(pointname: str) -> AccessPoint: 69 | """更新某一个访问点信息""" 70 | required: int = request.form.get("required", type=int, default=0) 71 | strict: bool = bool(request.form.get("strict", type=int, default=0)) 72 | description: str = request.form.get("description", type=str, default='') 73 | 74 | point: AccessPoint = funcs.Retrieve.byname(pointname) 75 | point.required = required 76 | point.strict = strict 77 | point.description = description 78 | point: AccessPoint = AccessPoint(pointname=pointname, required=required, 79 | strict=strict, description=description) 80 | 81 | return point.save() 82 | 83 | 84 | @rbac.route("/accesspoints//exceptions", methods=["PUT"]) 85 | @wrapper.require("leaf.views.rbac.accesspoint.update") 86 | @wrapper.wrap("accesspoint") 87 | def set_exceptions_user_for_accesspoint(pointname: str) -> AccessPoint: 88 | """为指定的 AccessPoint 管理特权用户""" 89 | point: AccessPoint = funcs.Retrieve.byname(pointname) 90 | raw: List[str] = [str(user) for user in point.exceptions] 91 | new: List[str] = web.JSONparser(request.form.get("users")) 92 | diff: Set[ObjectId] = set(new) - set(raw) 93 | 94 | # 检查每一个用户是否都存在 95 | exceptions = list() 96 | for userid in diff: 97 | user.Retrieve.byid(userid) 98 | exceptions.append(ObjectId(userid)) 99 | 100 | point.exceptions = exceptions 101 | return point.save() 102 | -------------------------------------------------------------------------------- /leaf/views/rbac/auth.py: -------------------------------------------------------------------------------- 1 | """控制用户的认证相关数据""" 2 | 3 | from typing import List, Tuple 4 | from flask import request 5 | 6 | from . import rbac 7 | 8 | from ...api import wrapper 9 | from ...api import validator 10 | 11 | from ...rbac import error 12 | from ...rbac.model import UserIndex 13 | from ...rbac.functions import auth as authfuncs 14 | from ...rbac.functions import user as userfuncs 15 | 16 | 17 | @rbac.route("/auths//", methods=["POST"]) 18 | @wrapper.require("leaf.views.rbac.auth.create", checkuser=True) 19 | @wrapper.wrap("status") 20 | def create_auth_with_user_index(userid: str, typeid: str) -> bool: 21 | """根据用户的某一个 index 创建 Auth 文档""" 22 | userid = validator.operator(userid) 23 | user = userfuncs.Retrieve.byid(userid) 24 | index: UserIndex = user.indexs.get(typeid=typeid) 25 | password: str = request.form.get("password", type=str) 26 | description: str = request.form.get("description", type=str, 27 | default=index.description) 28 | authfuncs.Create.withother(index.value, userid, 29 | password, description=description) 30 | return True 31 | 32 | 33 | @rbac.route("/auths//status", methods=["PUT"]) 34 | @wrapper.require("leaf.views.rbac.atuh.update") 35 | @wrapper.wrap("status") 36 | def update_status_for_authdoc(index: str) -> bool: 37 | """更新用户的认证文档状态""" 38 | status: bool = request.form.get("status", type=bool, default=True) 39 | authdoc = authfuncs.Retrieve.byindex(index) 40 | authdoc.status = status 41 | authdoc.save() 42 | return True 43 | 44 | 45 | @rbac.route("/auths//", methods=["DELETE"]) 46 | @wrapper.require("leaf.views.rbac.auth.delete", checkuser=True) 47 | @wrapper.wrap("status") 48 | def delete_authdoc(userid: str, index: str) -> bool: 49 | """删除用户的某一种认证方式""" 50 | userid = validator.operator(userid) 51 | authfuncs.Delete.byindex(userid, index) 52 | return True 53 | 54 | 55 | @rbac.route("/auths/", methods=["GET"]) 56 | @wrapper.require("leaf.views.rbac.auth.get", checkuser=True) 57 | @wrapper.wrap("auths") 58 | def query_auth_map_with_userid(userid: str) -> List[Tuple[UserIndex, bool]]: 59 | """ 60 | 查询用户的索引与认证文档的对应状态 61 | 返回类似下面的返回值: 62 | [(UserIndex1, True), (UserIndex2, False), ...] 63 | """ 64 | userid = validator.operator(userid) 65 | user = userfuncs.Retrieve.byid(userid) 66 | indexs: List[UserIndex] = user.indexs 67 | mapping: List[Tuple[UserIndex, bool]] = list() 68 | 69 | for index in indexs: 70 | try: 71 | authfuncs.Retrieve.byindex(index.value) 72 | except error.AuthenticationNotFound as _error: 73 | mapping.append((index, False)) 74 | continue 75 | else: 76 | mapping.append((index, True)) 77 | 78 | return mapping 79 | 80 | 81 | @rbac.route("/auths//password", methods=["PUT"]) 82 | @wrapper.require("leaf.views.rbac.auth.update", checkuser=True) 83 | @wrapper.wrap("status") 84 | def update_password(userid: str) -> bool: 85 | """更新用户密码""" 86 | userid = validator.operator(userid) 87 | current: str = request.form.get("current", type=str) 88 | new: str = request.form.get("new", type=str) 89 | authfuncs.Update.password(userid, current, new) 90 | return True 91 | -------------------------------------------------------------------------------- /leaf/views/rbac/group.py: -------------------------------------------------------------------------------- 1 | """用户组管理视图函数""" 2 | 3 | from typing import List, Set 4 | 5 | from bson import ObjectId 6 | from flask import request 7 | 8 | from . import rbac 9 | 10 | from ...api import wrapper 11 | from ...api import validator 12 | from ...core.tools import web 13 | from ...rbac.model import Group 14 | from ...rbac.functions import group as funcs 15 | from ...rbac.functions import user as userfuncs 16 | 17 | 18 | @rbac.route("/groups/", methods=["GET"]) 19 | @wrapper.require("leaf.views.rbac.group.query") 20 | @wrapper.wrap("group") 21 | def query_group_byid(groupid: str) -> Group: 22 | """根据给定的 id 查找用户组""" 23 | groupid = validator.objectid(groupid) 24 | return funcs.Retrieve.byid(groupid) 25 | 26 | 27 | @rbac.route("/groups", methods=["GET"]) 28 | @wrapper.require("leaf.views.rbac.group.list") 29 | @wrapper.wrap("groups") 30 | def list_all_groups() -> List[Group]: 31 | """列出所有的用户组信息""" 32 | # pylint: disable=no-member 33 | return Group.objects 34 | 35 | 36 | @rbac.route("/groups/name/", methods=["GET"]) 37 | @wrapper.require("leaf.views.rbac.group.query") 38 | @wrapper.wrap("groups") 39 | def query_group_byname(name: str) -> List[Group]: 40 | """根据名称查找指定的用户组""" 41 | # pylint: disable=no-member 42 | return Group.objects(name=name) 43 | 44 | 45 | @rbac.route("/groups/", methods=["DELETE"]) 46 | @wrapper.require("leaf.views.rbac.group.delete") 47 | @wrapper.wrap("status") 48 | def delete_group(groupid: str) -> bool: 49 | """删除某一个特定的用户组""" 50 | groupid = validator.objectid(groupid) 51 | group: Group = funcs.Retrieve.byid(groupid) 52 | return group.delete() 53 | 54 | 55 | @rbac.route("/groups", methods=["POST"]) 56 | @wrapper.require("leaf.views.rbac.group.add") 57 | @wrapper.wrap("group") 58 | def add_group() -> Group: 59 | """"增加一个用户组""" 60 | name: str = request.form.get("name", type=str, default='') 61 | description: str = request.form.get("description", type=str, default='') 62 | permission: int = request.form.get("permission", type=int, default=0) 63 | group: Group = Group(name=name, description=description, permission=permission) 64 | return group.save() 65 | 66 | 67 | @rbac.route("/groups/", methods=["PUT"]) 68 | @wrapper.require("leaf.views.rbac.group.update") 69 | @wrapper.wrap("group") 70 | def update_group(groupid: str) -> Group: 71 | """更新某一个用户组的信息""" 72 | groupid = validator.objectid(groupid) 73 | group: Group = funcs.Retrieve.byid(groupid) 74 | name: str = request.form.get("name", type=str, default='') 75 | description: str = request.form.get("description", type=str, default='') 76 | permission: int = request.form.get("permission", type=int, default=0) 77 | group.name = name 78 | group.description = description 79 | group.permission = permission 80 | return group.save() 81 | 82 | 83 | @rbac.route("/groups//users", methods=["PUT"]) 84 | @wrapper.require("leaf.views.rbac.group.edituser") 85 | @wrapper.wrap("group") 86 | def add_users_to_group(groupid: str) -> Group: 87 | """ 88 | 编辑用户组中的用户: 89 | 计算所有增加的用户 - 对所有的增加用户进行加组操作 90 | 计算所有被移出组的用户 - 对所有的移出用户进行移出操作 91 | """ 92 | groupid = validator.objectid(groupid) 93 | group: Group = funcs.Retrieve.byid(groupid) 94 | raw: List[str] = [str(user) for user in group.users] 95 | new: List[str] = web.JSONparser(request.form.get("users")) 96 | 97 | # 集合计算增加与移除的部分 98 | removed: Set[ObjectId] = set(raw) - set(new) 99 | added: Set[ObjectId] = set(new) - set(raw) 100 | 101 | # 给用户添加组信息 102 | for userid in added: 103 | userfuncs.Create.group(userid, groupid) 104 | for userid in removed: 105 | userfuncs.Delete.group(userid, groupid) 106 | 107 | # 返回更新之后的用户组信息 108 | return funcs.Retrieve.byid(groupid) 109 | -------------------------------------------------------------------------------- /leaf/views/rbac/jwt.py: -------------------------------------------------------------------------------- 1 | """JWT 视图函数""" 2 | 3 | import logging 4 | from typing import List 5 | 6 | from flask import request 7 | from bson import ObjectId 8 | 9 | from . import rbac 10 | 11 | from ...api import wrapper 12 | from ...core import events 13 | from ...core import modules 14 | 15 | from ...rbac import jwt 16 | from ...rbac import error 17 | from ...rbac.model import Group 18 | from ...rbac.functions import user 19 | from ...rbac.functions import auth 20 | 21 | # 获取事件与日志管理器 22 | manager: events.Manager = modules.events 23 | logger = logging.getLogger("leaf.views.rbac.jwt") 24 | 25 | issued = events.Event("leaf.rbac.jwt.token.issued", ((ObjectId,), {}), 26 | description="签发了一个 JWT Token") 27 | failed = events.Event("leaf.rbac.jwt.token.authfailed", ((str, str), {}), 28 | description="在签发时遇到了验证错误") 29 | manager.add(issued) 30 | manager.add(failed) 31 | 32 | 33 | @rbac.route("/jwts/", methods=["POST"]) 34 | @wrapper.wrap("token") 35 | def issue_jwt_token(usertoken: str) -> str: 36 | """为指定的用户颁发 JWT Token""" 37 | password: str = request.form.get("password", default=str(), type=str) 38 | validation = auth.Generator.valid(usertoken, password) 39 | if not validation: 40 | failed.notify(usertoken, password) 41 | raise error.AuthenticationError(usertoken) 42 | 43 | # 获取认证信息 44 | authdoc = auth.Retrieve.byindex(usertoken) 45 | if not authdoc.status: 46 | failed.notify(usertoken, password) 47 | raise error.AuthenticationDisabled(usertoken) 48 | userid: ObjectId = authdoc.user.id 49 | salt: str = authdoc.salt 50 | 51 | # 获取用户组权限 52 | userdoc = user.Retrieve.byid(userid) 53 | groups: List[Group] = userdoc.groups 54 | permissions = tuple((group.permission for group in groups)) 55 | 56 | # 颁发 Token 57 | token = jwt.Token(salt) 58 | token.header() 59 | token.payload(issuer="leaf.views.jwt", audience=str(userid), 60 | other={jwt.settings.Payload.Permission: permissions}) 61 | issued.notify(userid) 62 | return token.issue() 63 | -------------------------------------------------------------------------------- /leaf/views/weixin.py: -------------------------------------------------------------------------------- 1 | """微信公众平台回调函数支持""" 2 | 3 | from flask import request 4 | from flask import Blueprint 5 | 6 | from ..core import modules 7 | 8 | from ..weixin import const 9 | from ..weixin import settings 10 | from ..weixin.reply import Event 11 | from ..weixin.reply import Message 12 | 13 | weixin = Blueprint("weixin", __name__) 14 | 15 | 16 | @weixin.route("/" + settings.Interface, methods=["GET", "POST"]) 17 | def message_and_event_preducer(): 18 | """处理微信发来的消息和事件""" 19 | 20 | # 微信消息加密与签名实例引用 21 | message_handler: Message = modules.weixin.message 22 | event_handler: Event = modules.weixin.event 23 | 24 | # 获取 url 参数和 post 参数 25 | message: str = request.data.decode() 26 | paramaters: dict = request.args.to_dict() 27 | 28 | # 是否为回显消息 29 | if const.Encrypt.URL.Key.Echo in paramaters.keys(): 30 | return paramaters.get(const.Encrypt.URL.Key.Echo) 31 | 32 | # 判断是消息类型还是事件类型 33 | try: 34 | if const.Message.Message in message: 35 | reply = message_handler.reply(paramaters, message) 36 | return reply 37 | 38 | if const.Message.Event in message: 39 | event_handler.handle(message) 40 | return settings.Message.EmptyReply 41 | 42 | # 当超时错误之后返回自定义空消息 43 | except TimeoutError as _error: 44 | return settings.Message.EmptyReply 45 | 46 | return settings.Message.EmptyReply 47 | -------------------------------------------------------------------------------- /leaf/views/wxpay.py: -------------------------------------------------------------------------------- 1 | """ 2 | 一个基于微信支付的消息过滤器: 3 | 因为微信支付平台的回传信息是有一定规则的 4 | 所以可以在这一步根据规则检验数据包是否合法 5 | 如果不合法则可以 raise 相关的错误给业务层处理 6 | 7 | 同时, 这个过滤器也还可以用作 views 支付视图函数中 8 | 用来处理回调函数的信息过滤 9 | """ 10 | 11 | import logging 12 | from xml.etree.ElementTree import ParseError 13 | 14 | from flask import Blueprint 15 | from flask import request 16 | 17 | from ..core import events 18 | from ..core import modules 19 | from ..core.tools import web 20 | 21 | from ..payments.wxpay import const 22 | from ..payments.wxpay import error 23 | from ..payments.wxpay import payment 24 | from ..payments.wxpay import settings 25 | from ..payments.wxpay import signature 26 | 27 | # 支付提醒的标准回复 28 | _STANDARD_REPLY = web.XMLcreater({"xml": { 29 | const.WXPayResponse.Status: const.WXPayResponse.Success, 30 | const.WXPayResponse.Message: const.WXPayResponse.Nothing 31 | }}, encoding=None) 32 | 33 | 34 | wxpay = Blueprint("wxpay", __name__) 35 | logger = logging.getLogger("leaf.views.wxpay") 36 | 37 | 38 | # 注册支付成功与失败提醒事件 39 | # 位置参数列表 - openid, payid, trade_out_no, cash_fee 40 | paysuccess = events.Event( 41 | "leaf.payments.wxpay.notify.pay.success", 42 | ((str, str, str, float), {}), 43 | "支付成功时的结果通知" 44 | ) 45 | 46 | # 增加退款原因参数 47 | payfail = events.Event( 48 | "leaf.payments.wxpay.notify.pay.fail", 49 | ((str, str, str, float, str), {}), 50 | "支付失败时的结果通知" 51 | ) 52 | 53 | refund = events.Event( 54 | "leaf.payments.wxpay.notify.refund.success", 55 | ((str, str, str, float), {}), 56 | "退款成功的结果通知" 57 | ) 58 | 59 | manager: events.Manager = modules.events 60 | manager.add(paysuccess) 61 | manager.add(payfail) 62 | manager.add(refund) 63 | 64 | 65 | @wxpay.route("/refund_notify", methods=["POST"]) 66 | def refund_notify_handler() -> str: 67 | """处理退款通知""" 68 | response = request.data.decode(encoding="utf-8", errors="ignore") 69 | 70 | # 暂未获取到官方文档, 在 logger 中打日志 71 | logger.info(response) 72 | 73 | return _STANDARD_REPLY 74 | 75 | 76 | @wxpay.route("/notify", methods=["POST"]) 77 | def payment_notify_handler() -> str: 78 | """微信支付付款结果通知处理函数""" 79 | response = request.data.decode(encoding="utf-8", errors="ignore") 80 | 81 | # 防止异常发生 82 | try: 83 | response = web.XMLparser(response) 84 | except ParseError as _error: 85 | logger.info(response) 86 | return _STANDARD_REPLY 87 | 88 | response = response.get(const.WXPayAddress.XMLTag, {}) 89 | sigtool: signature = modules.payments.wxpay.signature 90 | 91 | # 校验签名是否正确 92 | if not sigtool.verify(**response): 93 | try: 94 | raise error.WXPaySignatureError(str(response)) 95 | except error.WXPayError as _error: 96 | logger.error(_error) 97 | return _STANDARD_REPLY 98 | 99 | # 获取需要的信息 100 | openid = response.get(const.WXPayBasic.OpenID) 101 | orderid = response.get(const.WXPayOrder.ID.In) 102 | fee = response.get(const.WXPaymentNotify.Fee) 103 | transcationid = response.get(const.WXPayBasic.TransactionID) 104 | 105 | kvparas = dict() 106 | for key, value in settings.PaymentNotify.Keys.items(): 107 | kvparas[value] = response.get(key) 108 | 109 | # 检测业务状态 110 | result = response.get(const.WXPaymentNotify.Status) 111 | 112 | # 支付失败 113 | if result != const.WXPayResponse.Success: 114 | reason = response.get(const.WXPaymentNotify.Error.Description) 115 | payfail.notify(openid, transcationid, orderid, fee, reason, **kvparas) 116 | return _STANDARD_REPLY 117 | 118 | fee = payment.currency_reverter(int(fee)) 119 | paysuccess.notify(openid, transcationid, orderid, fee, **kvparas) 120 | 121 | return _STANDARD_REPLY 122 | -------------------------------------------------------------------------------- /leaf/weixin/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 微信公众平台支持模块: 3 | encrypt - 微信公众平台消息体加密函数 4 | const - 微信公众平台相关常量定义 5 | apis - 微信公众平台API接口实现集 6 | settings - 微信相关设置 7 | """ 8 | 9 | from . import apis 10 | from . import const 11 | from . import reply 12 | from . import settings 13 | from . import accesstoken 14 | 15 | from .encrypt import Encrypt 16 | -------------------------------------------------------------------------------- /leaf/weixin/accesstoken/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Leaf 微信公众平台AccessToken管理组件 3 | """ 4 | 5 | from .patch import Patcher 6 | from . import error 7 | from . import events 8 | from . import settings 9 | -------------------------------------------------------------------------------- /leaf/weixin/accesstoken/const.py: -------------------------------------------------------------------------------- 1 | """ 2 | AccessToken 获取常量定义 3 | """ 4 | 5 | # 请求中携带的参数键 6 | GRANT_TYPE = "grant_type" 7 | APPID = "appid" 8 | SECRET = "secret" 9 | TYPE = "client_credential" 10 | 11 | # 响应中携带的参数键 12 | TOKEN = "access_token" 13 | EXPIRES = "expires_in" 14 | 15 | # 错误响应中的键 16 | CODE = "errcode" 17 | MESSAGE = "errmsg" 18 | -------------------------------------------------------------------------------- /leaf/weixin/accesstoken/error.py: -------------------------------------------------------------------------------- 1 | """ 2 | AccessToken 获取插件错误类定义 3 | """ 4 | 5 | from ...core import error as _oerror 6 | 7 | Error = _oerror.Error 8 | 9 | 10 | class AConnectionError(Error): 11 | """获取过程中的网络错误""" 12 | code = 14101 13 | description = "在获取过程中遇到网络错误" 14 | 15 | 16 | class InvalidResponse(Error): 17 | """当收到了非法的响应时给出""" 18 | code = 14202 19 | description = "获取到了不正确的响应 - 留意中间人攻击" 20 | 21 | 22 | class ReachedMaxRetries(Error): 23 | """当达到了最大的重试次数时""" 24 | code = 14203 25 | description = "经过多次重试后仍无法正常更新 AccessToken - 插件将停止运行" 26 | 27 | 28 | class NoCachedAccessToken(Error): 29 | """暂时没有缓存的 AccessToken""" 30 | code = 14204 31 | description = "暂时没有缓存的 AccessToken" 32 | 33 | 34 | class InvalidAppID(Error): 35 | """不正确的 APPID""" 36 | code = 14013 37 | description = "不正确的 APPID" 38 | 39 | 40 | class InvalidAppSecret(Error): 41 | """不正确的 AppSecret""" 42 | code = 14125 43 | description = "不正确的 AppSecret" 44 | 45 | 46 | class IPLimited(Error): 47 | """IP 地址被限制""" 48 | code = 14164 49 | description = "请在公众平台添加当前 IP 到白名单" 50 | 51 | 52 | class AccessTokenStatusError(Error): 53 | """当前的 AccessToken 状态不正常时抛出""" 54 | code = 14205 55 | description = "缓存的 AccessToken 状态异常" 56 | -------------------------------------------------------------------------------- /leaf/weixin/accesstoken/events.py: -------------------------------------------------------------------------------- 1 | """ 2 | 注册插件事件 3 | """ 4 | 5 | from ...core import events 6 | 7 | # 注册更新事件与停止事件 8 | updated = events.Event("leaf.weixin.accesstoken.updated", ((str, int), {}), "Accesstoken 被更新") 9 | failed = events.Event("leaf.weixin.accesstoken.failed", ((), {}), "Accesstoken 更新失败") 10 | stopped = events.Event("leaf.weixin.accesstoken.stopped", ((), {}), "Accesstoken 插件停止运行") 11 | -------------------------------------------------------------------------------- /leaf/weixin/accesstoken/patch.py: -------------------------------------------------------------------------------- 1 | """AccessToken 获取与缓存器""" 2 | 3 | import json 4 | import logging 5 | import time as _t 6 | from typing import NoReturn 7 | 8 | from . import error 9 | from . import const 10 | from . import settings 11 | 12 | from ...core import modules 13 | from ...core import schedule 14 | from ...core.tools import web 15 | from ...core.tools import time 16 | from ...core.error import Messenger 17 | 18 | 19 | logger = logging.getLogger("leaf.weixin.accesstoken") 20 | 21 | 22 | class Patcher: 23 | """ 24 | Leaf 微信公众平台 AccessToken 获取插件: 25 | get - 获取当前已经缓存的 Token 26 | stop - 停止任务的继续运行 27 | update - 手动更新一次 ACToken 缓存 28 | """ 29 | 30 | def __init__(self, appid: str, secret: str): 31 | """ 32 | 获取器构造函数: 33 | appid - 微信公众平台提供的公众号 AppId 34 | secret - 微信公众平台提供的 AppSecret 35 | 通过创建 leaf 的计划任务来进行更新 36 | """ 37 | self.__status = False 38 | 39 | # 保存 APPID SECRET 40 | self.__appid = appid 41 | self.__secret = secret 42 | 43 | # 已经保存的缓存与过期时间 44 | self.__cache = str() 45 | self.__expire = 0 46 | 47 | # 设置 schedule 模块进行自动更新 48 | self.__work: schedule.Worker = schedule.Worker( 49 | self._do, settings.Gap) 50 | 51 | def set(self, cache: str, expire: int) -> NoReturn: 52 | """手动设置当前的 AccessToken 信息""" 53 | self.__cache = cache 54 | self.__expire = expire + time.now() 55 | 56 | def start(self): 57 | """开始任务执行""" 58 | manager: schedule.Manager = modules.schedules 59 | manager.start(self.__work) 60 | self.__status = True 61 | 62 | def _do(self, retires: int = settings.MaxRetries) -> NoReturn: 63 | """ 64 | 经过包装的 update 函数 65 | 有一个默认参数 retires = settings.MaxRetires 66 | 当 update 执行遇到错误时则将 retries 减一递归 67 | 当 retires = 0 时停止任务运行 68 | """ 69 | _t.sleep(settings.Pre) 70 | if retires == 0: 71 | self.stop() 72 | return 73 | 74 | try: 75 | self.update() 76 | except error.Error as _error: 77 | logger.error(_error) 78 | self._do(retires - 1) 79 | 80 | def update(self) -> str: 81 | """ 82 | AccessToken 插件获取函数 83 | 通过 web.get 更新 ACToken 的值并进行更新 84 | 当遇到错误之后进行指定次数的重试 85 | """ 86 | 87 | request = { 88 | const.GRANT_TYPE: const.TYPE, 89 | const.APPID: self.__appid, 90 | const.SECRET: self.__secret 91 | } # 请求的 GET 参数 92 | 93 | data, response = web.get(settings.Address, request) 94 | 95 | # 遇到网络错误 96 | if response.code != 200: 97 | failed = modules.events.event("leaf.weixin.accesstoken.failed") 98 | failed.boardcast() 99 | raise error.AConnectionError(response.code) 100 | 101 | # 尝试解析数据 102 | try: 103 | info: dict = web.JSONparser(data) 104 | token: str = info[const.TOKEN] 105 | expires: int = int(info[const.EXPIRES]) 106 | except json.decoder.JSONDecodeError as _error: 107 | raise error.InvalidResponse(_error) 108 | except KeyError as _error: 109 | # 这时微信平台返回了异常提示 110 | code = int(info.get(const.CODE, 0)) 111 | message = info.get(const.MESSAGE, '') 112 | raiser: Messenger = modules.error.messenger 113 | raise raiser.error(code, message=message) 114 | 115 | # 更新缓存 - 由广播通知的 update 事件处理 116 | # self.__cache = token 117 | # self.__expire = expires + time.now() 118 | 119 | # 发送事件通知 120 | updated = modules.events.event("leaf.weixin.accesstoken.updated") 121 | updated.boardcast(token, expires) 122 | 123 | return self.__cache 124 | 125 | def stop(self) -> NoReturn: 126 | """ 127 | 停止获取任务 128 | 发送任务停止通知 129 | """ 130 | if self.__status is False: 131 | return 132 | 133 | self.__work.stop() 134 | self.__status = False 135 | 136 | stopped = modules.events.event("leaf.weixin.accesstoken.stopped") 137 | stopped.boardcast() 138 | 139 | def restart(self) -> NoReturn: 140 | """ 141 | 先设置任务停止 142 | 之后重启任务 143 | """ 144 | # 这里不能调用 self.stop 145 | # 函数会触发事件通知使得插件状态失效 146 | self.__work.stop() 147 | self.__work.start() 148 | 149 | @property 150 | def status(self) -> bool: 151 | """返回当前更新器是否在工作""" 152 | return self.__status 153 | 154 | def get(self) -> str: 155 | """ 156 | 返回缓存的 APPID 157 | """ 158 | if self.__expire == 0 and not self.__status: 159 | raise error.ReachedMaxRetries("经过多次失败之后, 更新器已经停止工作") 160 | 161 | if self.__expire == 0: 162 | raise error.AccessTokenStatusError("正在获取token, 请稍后再试") 163 | 164 | if time.now() > self.__expire: 165 | raise error.AccessTokenStatusError("当前缓存已经超时且无法更新") 166 | 167 | return self.__cache 168 | -------------------------------------------------------------------------------- /leaf/weixin/accesstoken/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | AccessToken 获取配置 3 | """ 4 | 5 | # 默认最大的重试次数 - 大于零 6 | MaxRetries = 5 7 | 8 | # 每两次获取之间的时间间隔 9 | Pre = 30 10 | Gap = 7200 - Pre 11 | 12 | # 请求的地址 13 | Address = "https://api.weixin.qq.com/cgi-bin/token" 14 | -------------------------------------------------------------------------------- /leaf/weixin/apis/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 微信公众平台API接口实现: 3 | template - 模板消息 4 | user - 用户相关 5 | """ 6 | 7 | from .user import User as user 8 | from .template import TemplateMessage as template 9 | -------------------------------------------------------------------------------- /leaf/weixin/apis/template.py: -------------------------------------------------------------------------------- 1 | """微信公众平台模板消息API实现""" 2 | 3 | from typing import Optional 4 | 5 | from ...core.tools import web 6 | from ..const import Request 7 | 8 | 9 | class TemplateMessage: 10 | """ 11 | 微信公众平台模板消息API实现: 12 | setindustry - 设置模板所属行业 13 | """ 14 | 15 | @staticmethod 16 | def setindustry(accesstoken: str, firstid: int, secondid: int) -> dict: 17 | """ 18 | 设置模板消息库所属行业: 19 | firstid: 第一个行业 id 20 | secondid: 第二个行业 id 21 | """ 22 | # 创建发送消息 23 | address = "https://api.weixin.qq.com/cgi-bin/template/api_set_industry" 24 | param = {Request.AccessToken: accesstoken} 25 | data = { 26 | Request.Template.IndustryFirst: firstid, 27 | Request.Template.IndustrySecond: secondid 28 | } 29 | request = web.JSONcreater(data) 30 | 31 | # 发送消息 32 | response, _ = web.post(address, param, request) 33 | response = web.JSONparser(response) 34 | return response 35 | 36 | @staticmethod 37 | def getindustry(accesstoken: str) -> dict: 38 | """ 39 | 获取当前设置的行业信息 40 | """ 41 | # 创建发送消息 42 | address = "https://api.weixin.qq.com/cgi-bin/template/get_industry" 43 | param = {Request.AccessToken: accesstoken} 44 | 45 | # 发送消息 46 | response, _ = web.get(address, param) 47 | response = web.JSONparser(response) 48 | return response 49 | 50 | @staticmethod 51 | def add(accesstoken: str, templateid: str) -> dict: 52 | """ 53 | 向模板库中下载一个模板: 54 | templateid: 要增加的模板id 55 | """ 56 | # 创建发送消息 57 | address = "https://api.weixin.qq.com/cgi-bin/template/api_add_template" 58 | param = {Request.AccessToken: accesstoken} 59 | request = {Request.Template.ID: templateid} 60 | request = web.JSONcreater(request) 61 | 62 | # 发送请求 63 | response, _ = web.post(address, param, request) 64 | response = web.JSONparser(response) 65 | return response 66 | 67 | @staticmethod 68 | def get(accesstoken: str) -> dict: 69 | """ 70 | 获取本地模板库中所有的模板 71 | """ 72 | # 创建发送的消息 73 | address = "https://api.weixin.qq.com/cgi-bin/template/get_all_private_template" 74 | param = {Request.AccessToken: accesstoken} 75 | 76 | # 发送请求 77 | response, _ = web.get(address, param) 78 | response = web.JSONparser(response) 79 | return response 80 | 81 | @staticmethod 82 | def delete(accesstoken: str, templateid: str) -> dict: 83 | """ 84 | 删除库中已经下载好的某个模板: 85 | templateid: 要删除的模板id 86 | """ 87 | # 创建发送消息 88 | address = "https://api.weixin.qq.com/cgi-bin/template/del_private_template" 89 | param = {Request.AccessToken: accesstoken} 90 | request = {Request.Template.ID: templateid} 91 | 92 | # 发送请求 93 | response, _ = web.post(address, param, request) 94 | response = web.JSONparser(response) 95 | return response 96 | 97 | @staticmethod 98 | def send(accesstoken: str, touser: str, templateid: str, 99 | data: dict, url: Optional[str] = None, 100 | appid: Optional[str] = None, 101 | pagepath: Optional[str] = None) -> dict: 102 | """ 103 | 请求发送一个模板消息: 104 | touser: 要发送用户的 openid 105 | template_id: 要发送的模板 id 106 | data: 要发送的模板数据 107 | url: 跳转的网页链接 108 | appid: 要跳转的小程序 appid 109 | miniprogram: 要跳转的小程序数据 110 | pagepath: 要跳转的小程序路径 111 | *注意: 112 | 后面两个参数用来指定小程序跳转的相关参数, 113 | 当不指定时不进行跳转, 要提供时需要一起提供 114 | *data数据格式: 115 | { 116 | "格式化参数": ("数据", "颜色代码"), 117 | "first": ("您已经订阅成功", "#CCCAAA"), 118 | ... 119 | } 120 | """ 121 | # 准备发送数据 122 | address = "https://api.weixin.qq.com/cgi-bin/message/template/send" 123 | param = {Request.AccessToken: accesstoken} 124 | sendata = dict() 125 | 126 | # 遍历生成数据 127 | for key, parameter in data.items(): 128 | item, color = parameter 129 | sendata[key] = { 130 | Request.Template.Data.Value: item, 131 | Request.Template.Data.Color: color 132 | } 133 | 134 | request = { 135 | Request.Template.Data.Key: sendata, 136 | Request.Template.ToUser: touser, 137 | Request.Template.ID: templateid, 138 | } 139 | 140 | # 添加链接跳转 141 | if not url is None: 142 | request[Request.Template.JumpTo] = url 143 | 144 | # 添加小程序跳转信息 145 | if appid and pagepath: 146 | miniprogram = { 147 | Request.MiniProgram.AppID: appid, 148 | Request.MiniProgram.PagePath: pagepath 149 | } 150 | request[Request.Template.MiniProgram] = miniprogram 151 | 152 | request = web.JSONcreater(request) 153 | 154 | # 发送数据 155 | response, _ = web.post(address, param, request) 156 | response = web.JSONparser(response) 157 | return response 158 | -------------------------------------------------------------------------------- /leaf/weixin/apis/user.py: -------------------------------------------------------------------------------- 1 | """微信公众平台用户相关API支持""" 2 | 3 | from typing import Iterable, Optional 4 | 5 | from ...core.tools import web 6 | from .. import settings 7 | from ..const import Request 8 | 9 | 10 | class User: 11 | """ 12 | 微信公众平台用户相关功能支持: 13 | remark - 给用户设置备注 14 | info - 获取单个用户信息 15 | patch - 批量获取用户信息 16 | """ 17 | @staticmethod 18 | def remark(accesstoken: str, openid: str, name: str) -> dict: 19 | """ 20 | 给用户设置新的备注名: 21 | openid: 用户的 openid 22 | name: 用户的新昵称 23 | """ 24 | # 创建发送地址和发送数据 25 | address = "https://api.weixin.qq.com/cgi-bin/user/info/updateremark" 26 | data = web.JSONcreater({ 27 | Request.User.OpenID: openid, 28 | Request.User.Remark: name 29 | }) 30 | 31 | # 发送请求 32 | response, _ = web.post( 33 | address, {Request.AccessToken: accesstoken}, data) 34 | response = web.JSONparser(response) 35 | return response 36 | 37 | @staticmethod 38 | def info(accesstoken: str, openid: str, 39 | language: Optional[str] = settings.User.Language) -> dict: 40 | """ 41 | 获取单个用户信息: 42 | openid: 要获取信息的用户openid 43 | """ 44 | # 创建发送数据 45 | address = "https://api.weixin.qq.com/cgi-bin/user/info" 46 | data = { 47 | Request.AccessToken: accesstoken, 48 | Request.User.OpenID: openid, 49 | Request.User.Language: language 50 | } 51 | 52 | # 发送数据 53 | response, _ = web.get(address, data) 54 | response = web.JSONparser(response) 55 | return response 56 | 57 | @staticmethod 58 | def patch(accesstoken: str, openids: Iterable, 59 | language: Optional[str] = settings.User.Language) -> dict: 60 | """ 61 | 批量获取用户信息: 62 | openids: 批量获取信息的用户 openid 列表 63 | """ 64 | # 创建发送数据 65 | address = "https://api.weixin.qq.com/cgi-bin/user/info/batchget" 66 | 67 | # 按照微信的官方格式转换信息 68 | userlist = list() 69 | data = {Request.User.UserList: userlist} 70 | for openid in openids: 71 | userlist.append({ 72 | Request.User.OpenID: openid, 73 | Request.User.Language: language 74 | }) 75 | 76 | # 发送数据 77 | request = web.JSONcreater(data) 78 | response, _ = web.post( 79 | address, {Request.AccessToken: accesstoken}, request) 80 | response = web.JSONparser(response) 81 | return response 82 | 83 | @staticmethod 84 | def userlist(accesstoken: str, starting: Optional[str] = None) -> dict: 85 | """ 86 | 获取关注公众号的用户 OpenID 列表: 87 | starting: 获取的第一个用户 OpenID - 不填则为从第一个开始 88 | """ 89 | # 创建发送数据 90 | address = "https://api.weixin.qq.com/cgi-bin/user/get" 91 | if starting: 92 | params = { 93 | Request.AccessToken: accesstoken, 94 | Request.User.StartingPosition: starting 95 | } 96 | else: 97 | params = { 98 | Request.AccessToken: accesstoken 99 | } 100 | 101 | # 发送数据 102 | response, _ = web.get(address, params) 103 | response = web.JSONparser(response) 104 | return response 105 | -------------------------------------------------------------------------------- /leaf/weixin/encrypt.py: -------------------------------------------------------------------------------- 1 | """微信公众平台消息加解密支持""" 2 | 3 | from ..core.tools import encrypt 4 | from . import const 5 | 6 | 7 | class Encrypt: 8 | """ 9 | 为微信公众平台封装的消息加解密类 10 | """ 11 | def __repr__(self) -> str: 12 | """返回 repr 信息""" 13 | return "" 14 | 15 | def __init__(self, aeskey: str, appid: str, token: str): 16 | """ 17 | 加解密类构造函数: 18 | aeskey: 公众平台提供的 AESEncodingKey 19 | appid: 用户的 AppID 20 | token: 用户的 Token 21 | *注意: 这里的 Token 和 AccessToken 不是一个东西 22 | """ 23 | # 公众平台提供的 AESEncodingKey 需要加上 = 之后进行 base64encode 使用 24 | self.__aeskey = encrypt.base64decode(aeskey + '=') 25 | 26 | # 保存 appid 和 token 27 | self.__appid = appid 28 | self.__token = token 29 | 30 | def encrypt(self, clear: str) -> str: 31 | """ 32 | 加密函数 - 返回加密过后的密文 33 | clear - 未加密的明文: 34 | 1. 生成 16 位的随机 ASCII 字符串作为补位 -> rand = 16 bytes 35 | 2. 计算网络字节补位 b'x00x00x00x??' -> pad = 4 bytes 36 | 3. 计算 rand + pad + clear.encode() + appid.encode() -> msg 37 | 4. 对 msg PKCS7 补码(32 位补码) -> aligned = 32 * n bytes 38 | 5. 对 aligned 使用 self.__key 进行 AES.CBC 模式加密 -> cipher 39 | 6. 对 cipher 再一次进行 base64 编码 40 | 41 | *注意: 这里的明文指的是未经加密的XML全部字符串 42 | """ 43 | # 准备加密需要的信息 44 | randstr: bytes = encrypt.random(const.Encrypt.PadLength).encode() 45 | pad: bytes = encrypt.packer(clear) 46 | msg: bytes = randstr + pad + clear.encode() + self.__appid.encode() 47 | aligned: bytes = encrypt.PKCS7encode(msg) 48 | 49 | # 进行 AES 加密 50 | cipher: bytes = encrypt.AESencrypt(aligned, self.__aeskey) 51 | cipher: bytes = encrypt.base64encode(cipher) 52 | 53 | return cipher.decode() 54 | 55 | def decrypt(self, cipher: bytes) -> str: 56 | """ 57 | 解密函数 - 返回解密后的明文 58 | 按照 encrypt 加密函数倒序来一遍 59 | """ 60 | # 按照顺序解密 61 | decoded: bytes = encrypt.base64decode(cipher) 62 | clear: bytes = encrypt.AESdecrypt(decoded, self.__aeskey) 63 | clear: bytes = encrypt.PKCS7decode(clear) 64 | 65 | # 前 16 为为随机字符串 - 取 16 位之后的信息 66 | content: bytes = clear[16:] 67 | 68 | # 去除 4bytes 的网络字节补位 69 | content = encrypt.unpacker(content) 70 | 71 | # 去除后 18 位的 APPID 72 | clear = content[0: -18] 73 | 74 | return clear.decode() 75 | 76 | def signature(self, message: str, timestamp: str, nonce: str) -> str: 77 | """ 78 | 根据密文计算消息体签名: 79 | 1. 对 token, timestamp, nonce, msg_encrypted 进行排序 80 | 2. 排序之后进行字符串拼接 81 | 3. 返回拼接过后字符串的 SHA1 值 82 | """ 83 | calculation = [message, timestamp, nonce, self.__token] 84 | calculation.sort() 85 | 86 | combined: str = ''.join(calculation) 87 | siganture: str = encrypt.SHA1(combined) 88 | 89 | return siganture 90 | -------------------------------------------------------------------------------- /leaf/weixin/errcodes.json: -------------------------------------------------------------------------------- 1 | { 2 | "40001": "获取 access_token 时 AppSecret 错误,或者 access_token 无效。请开发者认真比对 AppSecret 的正确性,或查看是否正在为恰当的公众号调用接口", 3 | "40002": "不合法的凭证类型", 4 | "40003": "不合法的 OpenID ,请开发者确认 OpenID (该用户)是否已关注公众号,或是否是其他公众号的 OpenID", 5 | "40004": "不合法的媒体文件类型", 6 | "40005": "不合法的文件类型", 7 | "40006": "不合法的文件大小", 8 | "40007": "不合法的媒体文件 id", 9 | "40008": "不合法的消息类型", 10 | "40009": "不合法的图片文件大小", 11 | "40010": "不合法的语音文件大小", 12 | "40011": "不合法的视频文件大小", 13 | "40012": "不合法的缩略图文件大小", 14 | "40013": "不合法的 AppID ,请开发者检查 AppID 的正确性,避免异常字符,注意大小写", 15 | "40014": "不合法的 access_token ,请开发者认真比对 access_token 的有效性(如是否过期),或查看是否正在为恰当的公众号调用接口", 16 | "40015": "不合法的菜单类型", 17 | "40016": "不合法的按钮个数", 18 | "40017": "不合法的按钮类型", 19 | "40018": "不合法的按钮名字长度", 20 | "40019": "不合法的按钮 KEY 长度", 21 | "40020": "不合法的按钮 URL 长度", 22 | "40021": "不合法的菜单版本号", 23 | "40022": "不合法的子菜单级数", 24 | "40023": "不合法的子菜单按钮个数", 25 | "40024": "不合法的子菜单按钮类型", 26 | "40025": "不合法的子菜单按钮名字长度", 27 | "40026": "不合法的子菜单按钮 KEY 长度", 28 | "40027": "不合法的子菜单按钮 URL 长度", 29 | "40028": "不合法的自定义菜单使用用户", 30 | "40029": "无效的 oauth_code", 31 | "40030": "不合法的 refresh_token", 32 | "40031": "不合法的 openid 列表", 33 | "40032": "不合法的 openid 列表长度", 34 | "40033": "不合法的请求字符,不能包含 \\uxxxx 格式的字符", 35 | "40035": "不合法的参数", 36 | "40038": "不合法的请求格式", 37 | "40039": "不合法的 URL 长度", 38 | "40048": "无效的url", 39 | "40050": "不合法的分组 id", 40 | "40051": "分组名字不合法", 41 | "40060": "删除单篇图文时,指定的 article_idx 不合法", 42 | "40117": "分组名字不合法", 43 | "40118": "media_id 大小不合法", 44 | "40119": "button 类型错误", 45 | "40120": "子 button 类型错误", 46 | "40121": "不合法的 media_id 类型", 47 | "40125": "无效的appsecret", 48 | "40132": "微信号不合法", 49 | "40137": "不支持的图片格式", 50 | "40155": "请勿添加其他公众号的主页链接", 51 | "40163": "oauth_code已使用", 52 | "40164": "调用接口的IP地址不在白名单中,请在接口IP白名单中进行设置。(小程序及小游戏调用不要求IP地址在白名单内。)", 53 | "41001": "缺少 access_token 参数", 54 | "41002": "缺少 appid 参数", 55 | "41003": "缺少 refresh_token 参数", 56 | "41004": "缺少 secret 参数", 57 | "41005": "缺少多媒体文件数据", 58 | "41006": "缺少 media_id 参数", 59 | "41007": "缺少子菜单数据", 60 | "41008": "缺少 oauth code", 61 | "41009": "缺少 openid", 62 | "42001": "access_token 超时,请检查 access_token 的有效期,请参考基础支持 - 获取 access_token 中,对 access_token 的详细机制说明", 63 | "42002": "refresh_token 超时", 64 | "42003": "oauth_code 超时", 65 | "42007": "用户修改微信密码, accesstoken 和 refreshtoken 失效,需要重新授权", 66 | "43001": "需要 GET 请求", 67 | "43002": "需要 POST 请求", 68 | "43003": "需要 HTTPS 请求", 69 | "43004": "需要接收者关注", 70 | "43005": "需要好友关系", 71 | "43019": "需要将接收者从黑名单中移除", 72 | "44001": "多媒体文件为空", 73 | "44002": "POST 的数据包为空", 74 | "44003": "图文消息内容为空", 75 | "44004": "文本消息内容为空", 76 | "45001": "多媒体文件大小超过限制", 77 | "45002": "消息内容超过限制", 78 | "45003": "标题字段超过限制", 79 | "45004": "描述字段超过限制", 80 | "45005": "链接字段超过限制", 81 | "45006": "图片链接字段超过限制", 82 | "45007": "语音播放时间超过限制", 83 | "45008": "图文消息超过限制", 84 | "45009": "接口调用超过限制", 85 | "45010": "创建菜单个数超过限制", 86 | "45011": "API 调用太频繁,请稍候再试", 87 | "45015": "回复时间超过限制", 88 | "45016": "系统分组,不允许修改", 89 | "45017": "分组名字过长", 90 | "45018": "分组数量超过上限", 91 | "45047": "客服接口下行条数超过上限", 92 | "45064": "创建菜单包含未关联的小程序", 93 | "45065": "相同 clientmsgid 已存在群发记录,返回数据中带有已存在的群发任务的 msgid", 94 | "45066": "相同 clientmsgid 重试速度过快,请间隔1分钟重试", 95 | "45067": "clientmsgid 长度超过限制", 96 | "46001": "不存在媒体数据", 97 | "46002": "不存在的菜单版本", 98 | "46003": "不存在的菜单数据", 99 | "46004": "不存在的用户", 100 | "47001": "解析 JSON/XML 内容错误", 101 | "48001": "api 功能未授权,请确认公众号已获得该接口,可以在公众平台官网 - 开发者中心页中查看接口权限", 102 | "48002": "粉丝拒收消息(粉丝在公众号选项中,关闭了 “ 接收消息 ” )", 103 | "48004": "api 接口被封禁,请登录 mp.weixin.qq.com 查看详情", 104 | "48005": "api 禁止删除被自动回复和自定义菜单引用的素材", 105 | "48006": "api 禁止清零调用次数,因为清零次数达到上限", 106 | "48008": "没有该类型消息的发送权限", 107 | "50001": "用户未授权该 api", 108 | "50002": "用户受限,可能是违规后接口被封禁", 109 | "50005": "用户未关注公众号", 110 | "61451": "参数错误 (invalid parameter)", 111 | "61452": "无效客服账号 (invalid kf_account)", 112 | "61453": "客服帐号已存在 (kf_account exsited)", 113 | "61454": "客服帐号名长度超过限制 (仅允许 10 个英文字符,不包括 @ 及 @ 后的公众号的微信号)(invalid kf_acount length)", 114 | "61455": "客服帐号名包含非法字符 (仅允许英文 + 数字)(illegal character in kf_account)", 115 | "61456": "客服帐号个数超过限制 (10 个客服账号)(kf_account count exceeded)", 116 | "61457": "无效头像文件类型 (invalid file type)", 117 | "61450": "系统错误 (system error)", 118 | "61500": "日期格式错误", 119 | "63001": "部分参数为空", 120 | "63002": "无效的签名", 121 | "65301": "不存在此 menuid 对应的个性化菜单", 122 | "65302": "没有相应的用户", 123 | "65303": "没有默认菜单,不能创建个性化菜单", 124 | "65304": "MatchRule 信息为空", 125 | "65305": "个性化菜单数量受限", 126 | "65306": "不支持个性化菜单的帐号", 127 | "65307": "个性化菜单信息为空", 128 | "65308": "包含没有响应类型的 button", 129 | "65309": "个性化菜单开关处于关闭状态", 130 | "65310": "填写了省份或城市信息,国家信息不能为空", 131 | "65311": "填写了城市信息,省份信息不能为空", 132 | "65312": "不合法的国家信息", 133 | "65313": "不合法的省份信息", 134 | "65314": "不合法的城市信息", 135 | "65316": "该公众号的菜单设置了过多的域名外跳(最多跳转到 3 个域名的链接)", 136 | "65317": "不合法的 URL", 137 | "87009": "无效的签名", 138 | "89503": "此IP调用需要管理员确认,请联系管理员", 139 | "89501": "此IP正在等待管理员确认,请联系管理员", 140 | "89506": "24小时内该IP被管理员拒绝调用两次,24小时内不可再使用该IP调用", 141 | "89507": "1小时内该IP被管理员拒绝调用一次,1小时内不可再使用该IP调用" 142 | } -------------------------------------------------------------------------------- /leaf/weixin/error.py: -------------------------------------------------------------------------------- 1 | """ 2 | 微信公众平台错误消息相关 3 | """ 4 | 5 | from ..core import error 6 | 7 | 8 | class SignatureError(error.Error): 9 | """消息体签名错误""" 10 | code = 12001 11 | description = "对消息体的签名验证出现错误" 12 | 13 | 14 | class EncryptError(error.Error): 15 | """消息体加密错误""" 16 | code = 12002 17 | description = "在消息体加密过程中出现错误" 18 | 19 | 20 | class DecryptError(error.Error): 21 | """消息体解密错误""" 22 | code = 12003 23 | description = "在消息体解密过程中发生错误" 24 | 25 | 26 | class InvalidMessage(error.Error): 27 | """消息体非法""" 28 | code = 12004 29 | description = "消息体不正确(键缺少/数据类型非法)" 30 | -------------------------------------------------------------------------------- /leaf/weixin/reply/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 微信公众平台消息回复实现工具 3 | maker - 消息回复制作器 4 | message - 消息类型回复 5 | event - 事件类型回复 6 | url - url 参数验证 7 | """ 8 | 9 | from .event import Event 10 | from .message import Message 11 | from .url import URLParamater as Url 12 | from .maker import MakeReply as Maker 13 | -------------------------------------------------------------------------------- /leaf/weixin/reply/event.py: -------------------------------------------------------------------------------- 1 | """ 2 | 微信公众平台事件类型处理 3 | """ 4 | 5 | import logging 6 | from collections import deque 7 | from typing import NoReturn 8 | 9 | from . import url 10 | 11 | from .. import const 12 | from .. import settings 13 | from ..encrypt import Encrypt 14 | 15 | from ...core import events 16 | from ...core import wrapper 17 | from ...core import modules 18 | 19 | logger = logging.getLogger("leaf.weixin.reply") 20 | subscribe = events.Event("leaf.weixin.events.subscribe", 21 | ((str, int), {}), "用户关注") 22 | unsubscribe = events.Event("leaf.weixin.events.unsubscribe", 23 | ((str, int), {}), "用户取消关注") 24 | scan = events.Event("leaf.weixin.events.scan", 25 | ((str, int), {"key": str, "ticket": str}), "用户扫描二维码") 26 | location = events.Event("leaf.weixin.events.location", 27 | ((str, int), {"latitude": str, 28 | "longitude": str, 29 | "precision": str}), 30 | "用户更新地理位置") 31 | click = events.Event("leaf.weixin.events.menu.click", 32 | ((str, int), {"key": str}), "用户调取菜单事件") 33 | view = events.Event("leaf.weixin.events.menu.view", 34 | ((str, int), {"key": str}), "用户点击菜单进行跳转") 35 | pushed = events.Event("leaf.weixin.events.push.success", 36 | ((str, str), {"id": str}), "模板消息推送完成(请自行判断是否成功)") 37 | 38 | 39 | class Event: 40 | """ 41 | 事件类型处理类: 42 | handle - 价格事件对应的名称传入, 调用所有绑定事件的函数 43 | """ 44 | 45 | def __init__(self, encryptor: Encrypt): 46 | """初始化事件处理函数""" 47 | # 注册事件 48 | manager: events.Manager = modules.events 49 | manager.add(subscribe) 50 | manager.add(unsubscribe) 51 | manager.add(scan) 52 | manager.add(location) 53 | manager.add(click) 54 | manager.add(view) 55 | manager.add(pushed) 56 | 57 | # 注册实例 58 | self.__encryptor = encryptor # 加解密实例 59 | self.__exclusion = deque( 60 | maxlen=settings.Message.ExclusionLength) # 消息排重队列 61 | 62 | @wrapper.timelimit(settings.Message.TimeOut) 63 | def handle(self, paramaters: list, request: str) -> NoReturn: 64 | """ 65 | 将事件数据包传入调用事件: 66 | paramaters: URL参数列表 67 | request: POST数据字典 68 | 判断加密与进行消息排重 69 | 根据事件类型提取信息 70 | 调用指定的事件 71 | """ 72 | _encrypted, message = url.verify(self.__encryptor, paramaters, request) 73 | 74 | # 获取用户openid, 事件时间, 事件类型 75 | user = message.get(const.Message.From) 76 | created = message.get(const.Message.CreateTime) 77 | event_type = message.get(const.Event.Type) 78 | 79 | # 进行消息排重 80 | msgid = user + created 81 | if msgid in self.__exclusion: 82 | return 83 | self.__exclusion.append(msgid) 84 | 85 | # 获取要拉取的事件与参数 86 | try: 87 | event_name = const.Event.Events.get(event_type) 88 | event: events.Event = modules.events.event(event_name) 89 | except events.EventNotFound as _error: 90 | logger.error(_error) 91 | return 92 | 93 | paras = dict() 94 | for key, value in const.Event.Types.get(event_type): 95 | paras[value] = message.get(key, '') 96 | 97 | # 拉动事件 98 | event.notify(user, int(created), **paras) 99 | -------------------------------------------------------------------------------- /leaf/weixin/reply/maker.py: -------------------------------------------------------------------------------- 1 | """ 2 | 微信公众平台消息回复制作器 3 | """ 4 | 5 | from typing import Optional, List 6 | from .. import const 7 | 8 | 9 | class MakeReply: 10 | """ 11 | 制作消息回复包 12 | 制作出的消息返回值格式为: 13 | msgtype: str - 消息类型 14 | msg: dict - 返回的消息 15 | 函数调用示例: 16 | MakeReply.text("HELLO WORLD!") -> 17 | ("text", {"Content": "HELLO WORLD!"}) 18 | """ 19 | @staticmethod 20 | def text(content: str) -> dict: 21 | """ 22 | 回复文本消息: 23 | content: 回复内容 24 | """ 25 | return { 26 | const.Message.Type: const.Reply.Types.Text, 27 | const.Reply.Text.Key: content 28 | } 29 | 30 | @staticmethod 31 | def picture(mediaid: str) -> dict: 32 | """ 33 | 回复图片消息: 34 | mediaid: 图片mediaid 35 | """ 36 | return { 37 | const.Message.Type: const.Reply.Types.Image, 38 | const.Reply.Image.Key: { 39 | const.Reply.Image.MediaId: mediaid 40 | } 41 | } 42 | 43 | @staticmethod 44 | def voice(mediaid: str) -> dict: 45 | """ 46 | 回复语音消息: 47 | mediaid: 语言 mediaid 48 | """ 49 | return { 50 | const.Message.Type: const.Reply.Types.Voice, 51 | const.Reply.Types.Voice: { 52 | const.Reply.Voice.MediaId: mediaid 53 | } 54 | } 55 | 56 | @staticmethod 57 | def video(mediaid: str, title: Optional[str] = None, 58 | description: Optional[str] = None) -> dict: 59 | """ 60 | 回复视频消息: 61 | mediaid: 视频 mediaid 62 | title: 标题 - 可选 63 | description: 描述 - 可选 64 | """ 65 | content = {const.Reply.Video.MediaId: mediaid} 66 | 67 | # 如果有附加的键则添加消息 68 | if not title is None: 69 | content[const.Reply.Video.Title] = title 70 | if not description is None: 71 | content[const.Reply.Video.Description] = description 72 | 73 | return { 74 | const.Message.Type: const.Reply.Types.Video, 75 | const.Reply.Types.Video: content 76 | } 77 | 78 | @staticmethod 79 | def music(thumb: str, title: Optional[str] = None, 80 | description: Optional[str] = None, 81 | url: Optional[str] = None, 82 | HQ: Optional[str] = None) -> dict: 83 | """ 84 | 回复音乐消息: 85 | thumb: 缩略图mediaid 86 | title: 标题 - 可选 87 | description: 描述 - 可选 88 | url: 地址 - 可选 89 | HQ: 高清地址 - 可选 90 | """ 91 | content = {const.Reply.Music.Thumb: thumb} 92 | 93 | # 添加可选信息 94 | if not title is None: 95 | content[const.Reply.Music.Title] = title 96 | if not description is None: 97 | content[const.Reply.Music.Description] = description 98 | if not url is None: 99 | content[const.Reply.Music.URL] = url 100 | if not HQ is None: 101 | content[const.Reply.Music.HQURL] = HQ 102 | 103 | return { 104 | const.Message.Type: const.Reply.Types.Music, 105 | const.Reply.Types.Music: content 106 | } 107 | 108 | @staticmethod 109 | def article(title: str, description: str, 110 | image: str, redirect: str) -> dict: 111 | """ 112 | 生成一条文章回复: 113 | title: 文章标题 114 | description: 文章描述 115 | image: 图片地址 116 | redirect: 跳转地址 117 | *请勿单独调用该函数作为回复 118 | *请将结果传入 articles 函数包装 119 | """ 120 | return { 121 | const.Reply.Article.Item.Title: title, 122 | const.Reply.Article.Item.Picture: image, 123 | const.Reply.Article.Item.URL: redirect, 124 | const.Reply.Article.Item.Descritpion: description 125 | } 126 | 127 | @staticmethod 128 | def articles(articles: List[dict]) -> dict: 129 | """ 130 | 回复图文消息: 131 | articles: 使用 article 函数生成的字典列表 132 | """ 133 | data = [{const.Reply.Article.Item.Key: item} for item in articles] 134 | return { 135 | const.Reply.Article.Count: len(articles), 136 | const.Reply.Types.Article: data 137 | } 138 | -------------------------------------------------------------------------------- /leaf/weixin/reply/message.py: -------------------------------------------------------------------------------- 1 | """ 2 | 微信公众平台消息接受与被动回复 3 | 4 | """ 5 | 6 | from collections import deque 7 | from typing import Callable, Tuple, Any 8 | 9 | from . import url 10 | 11 | from .. import const 12 | from .. import settings 13 | from ..encrypt import Encrypt 14 | 15 | from ...core import wrapper 16 | from ...core.tools import web 17 | from ...core.tools import time 18 | from ...core.tools import encrypt 19 | 20 | 21 | class Message: 22 | """ 23 | 消息回复管理器: 24 | register - 注册消息回复函数 25 | reply - 对给定的包进行回复(包含加/解密流程) 26 | """ 27 | 28 | def __init__(self, encryptor: Encrypt): 29 | """ 30 | 初始化一个消息回复管理器 31 | 以消息类型到用于处理消息函数的映射 32 | """ 33 | self.__encryptor = encryptor # 加密处理函数 34 | self.__registry = {} # 注册函数处理: str - callable 35 | self.__exclusion = deque( 36 | maxlen=settings.Message.ExclusionLength) # 消息排重队列 37 | 38 | def register(self, typing: str) -> Callable: 39 | """ 40 | 注册一种消息的回复函数 41 | 在微信发来的消息字段 MsgType 中定义的 42 | 字段参数不区分大小写, 调用示例: 43 | @weixin.reply.register("text") 44 | def text_reply(**kwargs): 45 | # 返回一样字符串 46 | content = kwargs.get("content") 47 | return content 48 | """ 49 | def _wrapper(function: Callable[[Any], Tuple[str, dict]]) -> Callable: 50 | self.__registry.update({typing: function}) 51 | 52 | return _wrapper 53 | 54 | @wrapper.timelimit(settings.Message.TimeOut) 55 | def reply(self, paramaters: list, request: str) -> str: 56 | """ 57 | 返回根据消息调用函数返回回复: 58 | paramaters: URL参数列表 59 | request: POST数据字典 60 | 判断加密与进行消息排重 61 | 根据消息类型提取出所有需要的信息 62 | 将信息传入取到的函数进行回复 63 | """ 64 | encrypted, message = url.verify(self.__encryptor, paramaters, request) 65 | 66 | # 获取五元素 67 | touser = message.get(const.Message.To) 68 | fromuser = message.get(const.Message.From) 69 | _created = message.get(const.Message.CreateTime) 70 | msgid = message.get(const.Message.Id) 71 | msgtype = message.get(const.Message.Type) 72 | 73 | # 进行消息排重 74 | if msgid in self.__exclusion: 75 | return settings.Message.EmptyReply 76 | self.__exclusion.append(msgid) 77 | 78 | # 获取需要从包中提取的信息 79 | needed_keys = const.Message.Types.get(msgtype) 80 | if needed_keys is None: 81 | return settings.Message.EmptyReply 82 | 83 | useful = dict() 84 | for key, value in needed_keys.items(): 85 | useful[value] = message.get(key, '') 86 | 87 | # 获取用于处理的函数 88 | handler = self.__registry.get(msgtype) 89 | if handler is None: 90 | return settings.Message.EmptyReply 91 | 92 | # 获取处理消息 93 | preduced: dict = handler(**useful) 94 | 95 | reply = { 96 | const.Message.From: touser, 97 | const.Message.To: fromuser, 98 | const.Message.CreateTime: time.now() 99 | } 100 | 101 | reply.update(preduced) 102 | reply = web.XMLcreater({const.Message.Root: reply}, encoding=False) 103 | 104 | # 设置回包加密 105 | if not encrypted: 106 | return reply 107 | 108 | nonce = encrypt.random(16) 109 | timestamp = time.nowstr() 110 | cipher = self.__encryptor.encrypt(reply) 111 | signature = self.__encryptor.signature( 112 | cipher, timestamp, nonce) 113 | 114 | reply = { 115 | const.Encrypt.Message.Content: cipher, 116 | const.Encrypt.Message.Signature: signature, 117 | const.Encrypt.Message.TimeStamp: timestamp, 118 | const.Encrypt.Message.Nonce: nonce 119 | } 120 | return web.XMLcreater({const.Message.Root: reply}, encoding=False) 121 | -------------------------------------------------------------------------------- /leaf/weixin/reply/url.py: -------------------------------------------------------------------------------- 1 | """URL 参数验证器""" 2 | 3 | from typing import Tuple 4 | 5 | from .. import const 6 | from .. import error 7 | 8 | from ..encrypt import Encrypt 9 | from ...core.tools import web 10 | 11 | 12 | class URLParamater: 13 | """URL 参数处理器""" 14 | 15 | @staticmethod 16 | def nonce(paramaters: dict) -> str: 17 | """返回 URL 中的 Nonce""" 18 | return paramaters.get(const.Encrypt.URL.Key.Nonce, '') 19 | 20 | @staticmethod 21 | def timestamp(paramaters: dict) -> str: 22 | """返回时间戳参数""" 23 | return paramaters.get(const.Encrypt.URL.Key.TimeStamp, '') 24 | 25 | @staticmethod 26 | def siganture(paramaters: dict) -> str: 27 | """返回消息体签名""" 28 | return paramaters.get(const.Encrypt.URL.Key.Signature, '') 29 | 30 | @staticmethod 31 | def encrypted(paramaters: dict) -> bool: 32 | """判断数据包是否是加密的""" 33 | encrypt_type = paramaters.get(const.Encrypt.URL.Key.Encrypted) 34 | if encrypt_type == const.Encrypt.URL.Value.Encrypted: 35 | return True 36 | return False 37 | 38 | 39 | def verify(encryptor: Encrypt, paramaters: list, request: str) -> Tuple[bool, dict]: 40 | """ 41 | 验证加密是否正确 42 | 尝试解密后将xml转为字典输出 43 | 返回是否加密的 bool 值与解包后的字典 44 | """ 45 | request = web.XMLparser(request) 46 | request = request.get(const.Message.Root) 47 | 48 | # 获取URL中的参数 49 | encrypted = URLParamater.encrypted(paramaters) 50 | signature = URLParamater.siganture(paramaters) 51 | nonce = URLParamater.nonce(paramaters) 52 | timestamp = URLParamater.timestamp(paramaters) 53 | 54 | # 判断是否加密 55 | if not encrypted: 56 | return False, request 57 | 58 | # 验证加密是否正确 59 | encrypted_msg: str = request.get(const.Encrypt.Message.Content) 60 | expectation = encryptor.signature( 61 | encrypted_msg, timestamp, nonce) 62 | if expectation != signature: 63 | raise error.SignatureError(expectation + ' - ' + signature) 64 | 65 | # 解密数据包 66 | message = dict() 67 | try: 68 | message = encryptor.decrypt(encrypted_msg.encode()) 69 | message = web.XMLparser(message) 70 | message = message.get(const.Message.Root) 71 | except ValueError as _error: 72 | raise error.DecryptError(_error) 73 | 74 | return True, message 75 | -------------------------------------------------------------------------------- /leaf/weixin/settings.py: -------------------------------------------------------------------------------- 1 | """微信公众平台设置""" 2 | 3 | import os 4 | 5 | Interface = "callback" # 微信公众平台的回调地址 6 | ErrcodesFile = os.path.dirname(os.path.realpath(__file__)) + "/" + "errcodes.json" # 错误代码存储文件 7 | 8 | class User: 9 | """用户信息相关设置""" 10 | Language = "zh_CN" # 获取用户信息的语言版本 11 | 12 | class Message: 13 | """消息相关设置""" 14 | EmptyReply = "success" # 回复空消息 15 | TimeOut = 4.1 # 每个回复消息的超时时间 - 推荐小于 5 秒 16 | ExclusionLength = 128 # 消息排重队列长度 17 | -------------------------------------------------------------------------------- /linting.py: -------------------------------------------------------------------------------- 1 | """Linting and return score as system code""" 2 | 3 | from os import system 4 | from pylint.lint import Run 5 | 6 | results = Run(["leaf"], do_exit=False) 7 | score = round(results.linter.stats["global_note"], 2) 8 | system('echo "::set-output name=score::' + str(score) + '"') 9 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==1.1.1 2 | pymongo==3.9.0 3 | mongoengine==0.18.2 4 | Werkzeug==0.16.0 5 | pycryptodome==3.9.4 6 | cacheout==0.11.2 7 | Pillow>=7.1.0 8 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | """框架运行示例文件""" 2 | 3 | import logging 4 | import flask 5 | 6 | import leaf 7 | import config 8 | 9 | init = leaf.Init() 10 | init.kernel(config.basic) 11 | init.logging(config.logging) 12 | init.server(config.devlopment) 13 | init.database(config.database) 14 | 15 | # 以下模块请根据需要启用/禁用初始化 16 | init.weixin(config.weixin) # 微信公众平台支持模块 17 | init.wxpay(config.wxpay) # 微信支付支持模块 18 | init.plugins(config.plugins) # 插件模块 19 | 20 | # 获取服务模块 21 | server: flask.Flask = leaf.modules.server 22 | 23 | # 以下模块的获取根据您上面的启用情况以及需求进行 24 | plugins: leaf.plugins.Manager = leaf.modules.plugins # 插件系统模块实例 25 | logger: logging.Logger = leaf.modules.logging.logger # 日志系统模块实例 26 | events: leaf.core.events.Manager = leaf.modules.events # 事件系统模块实例 27 | wxpay: leaf.core.algorithm.AttrDict = leaf.payments.wxpay # 微信支付模块实例 28 | weixin: leaf.core.algorithm.AttrDict = leaf.modules.weixin # 微信公众平台模块实例 29 | schedules: leaf.core.schedule.Manager = leaf.modules.schedules # 任务调度模块实例 30 | 31 | # 如果需要启用 Flask 自带的 Web Server 进行调试 32 | # 请取消下面一行的注释并给定 Flask 运行参数即可 33 | # server.run(host="127.0.0.1", port=8080) 34 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """打包发布文件""" 2 | 3 | import setuptools 4 | 5 | VERSION = "1.0.9.1.dev8" 6 | 7 | # 读取项目说明 8 | with open("README.md", "r", encoding="utf-8") as handler: 9 | long_description = handler.read() 10 | 11 | # 读取依赖 12 | with open("requirements.txt", "r") as handler: 13 | string = handler.read() 14 | pvs = string.split('\n') 15 | packages = [pv.split("==")[0] for pv in pvs if pv] 16 | # packages = [pv for pv in pvs if pv] 17 | 18 | setuptools.setup( 19 | name="wxleaf", 20 | version=VERSION, 21 | author="Gui Qiqi", 22 | license="Apache Software License", 23 | install_requires=packages, 24 | author_email="guiqiqi187@gmail.com", 25 | description="一个开发友好、功能完备的开源微信商城框架", 26 | long_description=long_description, 27 | long_description_content_type="text/markdown", 28 | url="https://github.com/guiqiqi/leaf", 29 | packages=setuptools.find_packages(), 30 | package_data={ 31 | "leaf.weixin": ["errcodes.json"] 32 | }, 33 | classifiers=[ 34 | "Programming Language :: Python :: 3.6", 35 | "Programming Language :: Python :: 3.7", 36 | "Programming Language :: Python :: 3.8", 37 | "Programming Language :: Python :: 3.9", 38 | "Development Status :: 1 - Planning", 39 | "Framework :: Flask", 40 | "Topic :: Software Development :: Libraries :: Application Frameworks", 41 | "License :: OSI Approved :: Apache Software License", 42 | "Natural Language :: Chinese (Simplified)", 43 | "Operating System :: OS Independent" 44 | ], 45 | python_requires=">=3.6" 46 | ) 47 | --------------------------------------------------------------------------------