From b2f23a933b1f974682844bd5f1e25928caa3ef92 Mon Sep 17 00:00:00 2001
From: zhuyiyi <649091362@qq.com>
Date: Thu, 25 Jun 2026 17:41:06 +0800
Subject: [PATCH] first commit
---
.dockerignore | 46 +
.gitattributes | 1 +
.gitignore | 35 +
Dockerfile | 63 +
LICENSE | 21 +
Makefile | 87 +
README-en.md | 219 +
README.md | 244 +
app/__init__.py | 49 +
app/api/__init__.py | 9 +
app/api/v1/__init__.py | 29 +
app/api/v1/apis/__init__.py | 8 +
app/api/v1/apis/apis.py | 67 +
app/api/v1/auditlog/__init__.py | 8 +
app/api/v1/auditlog/auditlog.py | 44 +
app/api/v1/automation/__init__.py | 8 +
app/api/v1/automation/automation.py | 188 +
app/api/v1/base/__init__.py | 8 +
app/api/v1/base/base.py | 103 +
app/api/v1/codegen/__init__.py | 10 +
app/api/v1/codegen/datasource.py | 73 +
app/api/v1/codegen/table.py | 81 +
app/api/v1/depts/__init__.py | 8 +
app/api/v1/depts/depts.py | 48 +
app/api/v1/finance_parse/__init__.py | 8 +
app/api/v1/finance_parse/finance_parse.py | 95 +
app/api/v1/menus/__init__.py | 8 +
app/api/v1/menus/menus.py | 63 +
app/api/v1/msg/__init__.py | 9 +
app/api/v1/msg/event.py | 15 +
app/api/v1/msg/msg.py | 80 +
app/api/v1/roles/__init__.py | 8 +
app/api/v1/roles/roles.py | 73 +
app/api/v1/users/__init__.py | 8 +
app/api/v1/users/users.py | 87 +
app/api/v1/weixin/__init__.py | 11 +
app/api/v1/weixin/base.py | 16 +
app/api/v1/weixin/requirements.txt | 2 +
app/api/v1/weixin/weixin.py | 95 +
app/api/v1/weixin/weixin_user.py | 294 +
app/controllers/__init__.py | 2 +
app/controllers/action.py | 109 +
app/controllers/api.py | 46 +
app/controllers/automation/action.py | 82 +
app/controllers/automation/scenario.py | 222 +
app/controllers/automation/task.py | 77 +
app/controllers/crm.py | 235 +
app/controllers/datasource.py | 41 +
app/controllers/dept.py | 86 +
app/controllers/finance_parse/__init__.py | 43 +
.../finance_parse/finance_parse.py | 616 ++
.../finance_parse/requirements.txt | 6 +
app/controllers/menu.py | 16 +
app/controllers/msg.py | 461 ++
app/controllers/role.py | 27 +
app/controllers/table.py | 62 +
app/controllers/user.py | 60 +
app/controllers/weixin/customer.py | 607 ++
app/controllers/weixin/group.py | 408 ++
app/controllers/weixin/schedule.py | 169 +
app/controllers/weixin/user.py | 201 +
app/controllers/weixin/utils.py | 4 +
app/controllers/weixin_sdk.py | 20 +
app/controllers/xingyun_sdk.py | 66 +
app/core/bgtask.py | 31 +
app/core/cache.py | 163 +
app/core/crud.py | 78 +
app/core/ctx.py | 17 +
app/core/dependency.py | 69 +
app/core/exceptions.py | 150 +
app/core/init_app.py | 273 +
app/core/middlewares.py | 192 +
app/http_base.py | 66 +
app/log/__init__.py | 1 +
app/log/log.py | 105 +
app/models/__init__.py | 5 +
app/models/admin.py | 297 +
app/models/automation.py | 119 +
app/models/base.py | 68 +
app/models/enums.py | 64 +
app/models/msg.py | 72 +
app/models/weixin.py | 260 +
app/resources/.gitignore | 13 +
...0616154526_多淘印6.16_解析_横向排列_1.xlsx | Bin 0 -> 432164 bytes
.../20260623112828_1_解析_横向排列_1.xlsx | Bin 0 -> 2413439 bytes
.../定制印刷品-订单描述/best_model/config.cfg | 135 +
.../定制印刷品-订单描述/best_model/meta.json | 41 +
.../定制印刷品-订单描述/best_model/ner/cfg | 13 +
.../定制印刷品-订单描述/best_model/ner/model | Bin 0 -> 3841240 bytes
.../定制印刷品-订单描述/best_model/ner/moves | 1 +
.../best_model/tokenizer/cfg | 3 +
.../best_model/vocab/key2row | 1 +
.../best_model/vocab/lookups.bin | 1 +
.../best_model/vocab/strings.json | 675 +++
.../best_model/vocab/vectors | Bin 0 -> 128 bytes
.../best_model/vocab/vectors.cfg | 3 +
.../models/订单尺寸识别/best_model/config.cfg | 135 +
.../models/订单尺寸识别/best_model/meta.json | 35 +
.../models/订单尺寸识别/best_model/ner/cfg | 13 +
.../models/订单尺寸识别/best_model/ner/model | Bin 0 -> 3835000 bytes
.../models/订单尺寸识别/best_model/ner/moves | 1 +
.../订单尺寸识别/best_model/tokenizer/cfg | 3 +
.../订单尺寸识别/best_model/vocab/key2row | 1 +
.../订单尺寸识别/best_model/vocab/lookups.bin | 1 +
.../best_model/vocab/strings.json | 928 +++
.../订单尺寸识别/best_model/vocab/vectors | Bin 0 -> 128 bytes
.../订单尺寸识别/best_model/vocab/vectors.cfg | 3 +
app/schemas/__init__.py | 1 +
app/schemas/apis.py | 23 +
app/schemas/automation.py | 164 +
app/schemas/base.py | 52 +
app/schemas/codegen.py | 48 +
app/schemas/crm.py | 170 +
app/schemas/customer.py | 61 +
app/schemas/depts.py | 18 +
app/schemas/login.py | 20 +
app/schemas/menus.py | 52 +
app/schemas/msg.py | 99 +
app/schemas/query.py | 27 +
app/schemas/roles.py | 32 +
app/schemas/task.py | 75 +
app/schemas/users.py | 49 +
app/schemas/weixin.py | 220 +
app/settings/__init__.py | 3 +
app/settings/config.py | 106 +
app/utils/codegen.py | 272 +
app/utils/common.py | 133 +
app/utils/db.py | 134 +
app/utils/event_task.py | 237 +
app/utils/excel_utils.py | 34 +
app/utils/jwt.py | 10 +
app/utils/password.py | 16 +
app/utils/some_sdk | 1 +
codegen/.env.development | 9 +
codegen/.env.production | 1 +
codegen/.eslintignore | 22 +
codegen/.eslintrc.cjs | 19 +
codegen/.gitignore | 31 +
codegen/.npmrc | 2 +
codegen/.prettierrc.cjs | 29 +
codegen/README.md | 19 +
codegen/build/config/define.js | 13 +
codegen/build/config/index.js | 1 +
codegen/build/constant.js | 23 +
codegen/build/plugin/html.js | 15 +
codegen/build/plugin/index.js | 41 +
codegen/build/plugin/unplugin.js | 46 +
codegen/build/script/build-cname.js | 15 +
codegen/build/script/index.js | 14 +
codegen/build/utils.js | 70 +
codegen/components.d.ts | 35 +
codegen/index.html | 14 +
codegen/package.json | 60 +
codegen/public/favicon.ico | Bin 0 -> 16958 bytes
codegen/src/App.vue | 14 +
codegen/src/api/baseClass.ts | 17 +
codegen/src/api/datasource.ts | 25 +
codegen/src/api/fieldType.ts | 17 +
codegen/src/api/generator.ts | 17 +
codegen/src/api/project.ts | 18 +
codegen/src/api/table.ts | 21 +
codegen/src/assets/404.png | Bin 0 -> 51678 bytes
.../src/components/codemirror/CodeMirror.vue | 56 +
codegen/src/components/svg-icon/index.ts | 4 +
.../src/components/svg-icon/src/svg-icon.vue | 40 +
codegen/src/hooks/index.ts | 164 +
codegen/src/hooks/interface.ts | 33 +
codegen/src/icons/iconfont/iconfont.js | 63 +
codegen/src/icons/svg/404.svg | 1 +
codegen/src/icons/svg/gitee.svg | 2 +
.../components/Navbar/components/Logo.vue | 27 +
.../src/layout/components/Navbar/index.vue | 64 +
.../src/layout/components/Router/Redirect.vue | 18 +
.../components/Sidebar/components/Menu.vue | 27 +
.../Sidebar/components/MenuItem.vue | 24 +
.../src/layout/components/Sidebar/index.vue | 9 +
codegen/src/layout/index.vue | 25 +
codegen/src/main.ts | 28 +
codegen/src/router/index.ts | 90 +
codegen/src/styles/element.scss | 96 +
codegen/src/styles/index.scss | 3 +
codegen/src/styles/layout.scss | 68 +
codegen/src/styles/variables.scss | 20 +
codegen/src/types/shims.d.ts | 13 +
codegen/src/utils/request.ts | 59 +
codegen/src/utils/token.js | 30 +
codegen/src/utils/tool.ts | 45 +
codegen/src/views/404.vue | 32 +
.../src/views/base-class/add-or-update.vue | 105 +
codegen/src/views/base-class/index.vue | 108 +
.../src/views/datasource/add-or-update.vue | 123 +
codegen/src/views/datasource/index.vue | 133 +
.../src/views/field-type/add-or-update.vue | 98 +
codegen/src/views/field-type/index.vue | 104 +
codegen/src/views/generator/edit.vue | 330 ++
codegen/src/views/generator/generator.vue | 238 +
codegen/src/views/generator/import.vue | 145 +
codegen/src/views/generator/index.vue | 167 +
codegen/src/views/generator/preview.vue | 189 +
codegen/src/views/project/add-or-update.vue | 102 +
codegen/src/views/project/download.vue | 139 +
codegen/src/views/project/index.vue | 114 +
codegen/tsconfig.json | 27 +
codegen/vite.config.ts | 54 +
deploy/entrypoint.sh | 5 +
deploy/sample-picture/1.jpg | Bin 0 -> 162646 bytes
deploy/sample-picture/2.jpg | Bin 0 -> 104510 bytes
deploy/sample-picture/3.jpg | Bin 0 -> 247552 bytes
deploy/sample-picture/api.jpg | Bin 0 -> 441828 bytes
deploy/sample-picture/group.jpg | Bin 0 -> 113629 bytes
deploy/sample-picture/login.jpg | Bin 0 -> 241207 bytes
deploy/sample-picture/logo.svg | 1 +
deploy/sample-picture/menu.jpg | Bin 0 -> 360884 bytes
deploy/sample-picture/role.jpg | Bin 0 -> 324107 bytes
deploy/sample-picture/user.jpg | Bin 0 -> 295713 bytes
deploy/sample-picture/workbench.jpg | Bin 0 -> 419082 bytes
deploy/web.conf | 12 +
docker-compose.yml | 37 +
pyproject.toml | 89 +
requirements.txt | 63 +
run.py | 13 +
scripts/build.sh | 20 +
scripts/build_and_sign.py | 367 ++
scripts/gen.sh | 1 +
scripts/generate_pyi.py | 125 +
scripts/setup_example.sh | 15 +
scripts/tranform_to_ico.py | 74 +
scripts/venv.sh | 23 +
static/swagger-ui/swagger-ui-bundle.js | 3 +
static/swagger-ui/swagger-ui.css | 3 +
.../app/api/v1/demo/__init__.py.mako | 0
.../app/api/v1/demo/demo.py.mako | 86 +
templates/relation-demo/model/demo.py.mako | 71 +
templates/relation-demo/schemas/demo.py.mako | 61 +
.../relation-demo/tests/test_demo.py.mako | 46 +
.../web/src/components/demo.vue.mako | 51 +
.../app/api/v1/demo/__init__.py.mako | 0
.../app/api/v1/demo/demo.py.mako | 73 +
.../vue-fastapi-admin/model/crud.py.mako | 0
.../vue-fastapi-admin/model/demo.py.mako | 21 +
.../vue-fastapi-admin/model/model.py.mako | 0
.../vue-fastapi-admin/schemas/demo.py.mako | 30 +
.../vue-fastapi-admin/tests/test_demo.py.mako | 46 +
.../web/src/components/demo.vue.mako | 51 +
uv.lock | 1202 ++++
web/.env | 3 +
web/.env.development | 8 +
web/.env.production | 11 +
web/.eslint-global-variables.json | 62 +
web/.eslintignore | 4 +
web/.gitignore | 26 +
web/.prettierignore | 3 +
web/.prettierrc.json | 6 +
web/README.md | 20 +
web/build/config/define.js | 13 +
web/build/config/index.js | 1 +
web/build/constant.js | 23 +
web/build/plugin/html.js | 15 +
web/build/plugin/index.js | 35 +
web/build/plugin/unplugin.js | 46 +
web/build/script/build-cname.js | 15 +
web/build/script/index.js | 14 +
web/build/utils.js | 70 +
web/i18n/index.js | 16 +
web/i18n/messages/cn.json | 62 +
web/i18n/messages/en.json | 62 +
web/i18n/messages/index.js | 7 +
web/index.html | 35 +
web/jsconfig.json | 14 +
web/package.json | 57 +
web/pnpm-lock.yaml | 5259 +++++++++++++++++
web/public/favicon.svg | 1 +
web/public/resource/loading.css | 91 +
web/public/resource/loading.js | 25 +
web/settings/index.js | 1 +
web/settings/theme.json | 37 +
web/src/App.vue | 11 +
web/src/api/finance_parse.js | 14 +
web/src/api/index.js | 43 +
web/src/assets/images/login_bg.webp | Bin 0 -> 6944 bytes
web/src/assets/js/icons.js | 230 +
web/src/assets/svg/forbidden.svg | 1 +
web/src/assets/svg/front-page.svg | 1 +
web/src/assets/svg/logo.svg | 1 +
web/src/assets/svg/network-error.svg | 1 +
web/src/assets/svg/no-data.svg | 1 +
web/src/assets/svg/not-found.svg | 1 +
web/src/assets/svg/server-error.svg | 1 +
web/src/assets/svg/service-unavailable.svg | 1 +
web/src/assets/svg/unauthorized.svg | 1 +
web/src/components/common/AppFooter.vue | 3 +
web/src/components/common/AppProvider.vue | 67 +
.../components/common/LoadingEmptyWrapper.vue | 82 +
web/src/components/common/ScrollX.vue | 160 +
web/src/components/icon/CustomIcon.vue | 22 +
web/src/components/icon/IconPicker.vue | 70 +
web/src/components/icon/SvgIcon.vue | 24 +
web/src/components/icon/TheIcon.vue | 22 +
web/src/components/page/AppPage.vue | 18 +
web/src/components/page/CommonPage.vue | 33 +
web/src/components/query-bar/QueryBar.vue | 26 +
web/src/components/query-bar/QueryBarItem.vue | 34 +
web/src/components/table/CrudModal.vue | 60 +
web/src/components/table/CrudTable.vue | 151 +
web/src/composables/index.js | 1 +
web/src/composables/useCRUD.js | 104 +
web/src/directives/index.js | 6 +
web/src/directives/permission.js | 34 +
web/src/layout/components/AppMain.vue | 23 +
.../header/components/BreadCrumb.vue | 30 +
.../header/components/FullScreen.vue | 14 +
.../header/components/GithubSite.vue | 11 +
.../header/components/Languages.vue | 33 +
.../header/components/MenuCollapse.vue | 12 +
.../header/components/ThemeMode.vue | 18 +
.../header/components/UserAvatar.vue | 50 +
web/src/layout/components/header/index.vue | 23 +
.../sidebar/components/SideLogo.vue | 24 +
.../sidebar/components/SideMenu.vue | 125 +
web/src/layout/components/sidebar/index.vue | 9 +
.../layout/components/tags/ContextMenu.vue | 123 +
web/src/layout/components/tags/index.vue | 101 +
web/src/layout/index.vue | 74 +
web/src/main.js | 26 +
web/src/router/guard/auth-guard.js | 18 +
web/src/router/guard/index.js | 9 +
web/src/router/guard/page-loading-guard.js | 15 +
web/src/router/guard/page-title-guard.js | 12 +
web/src/router/index.js | 67 +
web/src/router/routes/index.js | 144 +
web/src/store/index.js | 7 +
web/src/store/modules/app/index.js | 60 +
web/src/store/modules/index.js | 4 +
web/src/store/modules/permission/index.js | 93 +
web/src/store/modules/tags/helpers.js | 6 +
web/src/store/modules/tags/index.js | 70 +
web/src/store/modules/user/index.js | 65 +
web/src/styles/global.scss | 74 +
web/src/styles/reset.css | 35 +
web/src/utils/auth/auth.js | 17 +
web/src/utils/auth/index.js | 2 +
web/src/utils/auth/token.js | 31 +
web/src/utils/common/common.js | 76 +
web/src/utils/common/icon.js | 12 +
web/src/utils/common/index.js | 5 +
web/src/utils/common/is.js | 119 +
web/src/utils/common/naiveTools.js | 79 +
web/src/utils/common/useResize.js | 22 +
web/src/utils/http/helpers.js | 63 +
web/src/utils/http/index.js | 20 +
web/src/utils/http/interceptors.js | 67 +
web/src/utils/index.js | 4 +
web/src/utils/storage/index.js | 21 +
web/src/utils/storage/storage.js | 55 +
web/src/views/erp-dataparse/index.vue | 243 +
web/src/views/error-page/401.vue | 18 +
web/src/views/error-page/403.vue | 18 +
web/src/views/error-page/404.vue | 18 +
web/src/views/error-page/500.vue | 18 +
web/src/views/login/index.vue | 108 +
web/src/views/profile/index.vue | 202 +
web/src/views/system/api/index.vue | 268 +
web/src/views/system/auditlog/index.vue | 285 +
web/src/views/system/dept/index.vue | 217 +
web/src/views/system/menu/index.vue | 360 ++
web/src/views/system/role/index.vue | 362 ++
web/src/views/system/user/index.vue | 495 ++
web/src/views/workbench/index.vue | 74 +
web/unocss.config.js | 60 +
web/vite.config.js | 44 +
370 files changed, 30526 insertions(+)
create mode 100644 .dockerignore
create mode 100644 .gitattributes
create mode 100644 .gitignore
create mode 100644 Dockerfile
create mode 100644 LICENSE
create mode 100644 Makefile
create mode 100644 README-en.md
create mode 100644 README.md
create mode 100644 app/__init__.py
create mode 100644 app/api/__init__.py
create mode 100644 app/api/v1/__init__.py
create mode 100644 app/api/v1/apis/__init__.py
create mode 100644 app/api/v1/apis/apis.py
create mode 100644 app/api/v1/auditlog/__init__.py
create mode 100644 app/api/v1/auditlog/auditlog.py
create mode 100644 app/api/v1/automation/__init__.py
create mode 100644 app/api/v1/automation/automation.py
create mode 100644 app/api/v1/base/__init__.py
create mode 100644 app/api/v1/base/base.py
create mode 100644 app/api/v1/codegen/__init__.py
create mode 100644 app/api/v1/codegen/datasource.py
create mode 100644 app/api/v1/codegen/table.py
create mode 100644 app/api/v1/depts/__init__.py
create mode 100644 app/api/v1/depts/depts.py
create mode 100644 app/api/v1/finance_parse/__init__.py
create mode 100644 app/api/v1/finance_parse/finance_parse.py
create mode 100644 app/api/v1/menus/__init__.py
create mode 100644 app/api/v1/menus/menus.py
create mode 100644 app/api/v1/msg/__init__.py
create mode 100644 app/api/v1/msg/event.py
create mode 100644 app/api/v1/msg/msg.py
create mode 100644 app/api/v1/roles/__init__.py
create mode 100644 app/api/v1/roles/roles.py
create mode 100644 app/api/v1/users/__init__.py
create mode 100644 app/api/v1/users/users.py
create mode 100644 app/api/v1/weixin/__init__.py
create mode 100644 app/api/v1/weixin/base.py
create mode 100644 app/api/v1/weixin/requirements.txt
create mode 100644 app/api/v1/weixin/weixin.py
create mode 100644 app/api/v1/weixin/weixin_user.py
create mode 100644 app/controllers/__init__.py
create mode 100644 app/controllers/action.py
create mode 100644 app/controllers/api.py
create mode 100644 app/controllers/automation/action.py
create mode 100644 app/controllers/automation/scenario.py
create mode 100644 app/controllers/automation/task.py
create mode 100644 app/controllers/crm.py
create mode 100644 app/controllers/datasource.py
create mode 100644 app/controllers/dept.py
create mode 100644 app/controllers/finance_parse/__init__.py
create mode 100644 app/controllers/finance_parse/finance_parse.py
create mode 100644 app/controllers/finance_parse/requirements.txt
create mode 100644 app/controllers/menu.py
create mode 100644 app/controllers/msg.py
create mode 100644 app/controllers/role.py
create mode 100644 app/controllers/table.py
create mode 100644 app/controllers/user.py
create mode 100644 app/controllers/weixin/customer.py
create mode 100644 app/controllers/weixin/group.py
create mode 100644 app/controllers/weixin/schedule.py
create mode 100644 app/controllers/weixin/user.py
create mode 100644 app/controllers/weixin/utils.py
create mode 100644 app/controllers/weixin_sdk.py
create mode 100644 app/controllers/xingyun_sdk.py
create mode 100644 app/core/bgtask.py
create mode 100644 app/core/cache.py
create mode 100644 app/core/crud.py
create mode 100644 app/core/ctx.py
create mode 100644 app/core/dependency.py
create mode 100644 app/core/exceptions.py
create mode 100644 app/core/init_app.py
create mode 100644 app/core/middlewares.py
create mode 100644 app/http_base.py
create mode 100644 app/log/__init__.py
create mode 100644 app/log/log.py
create mode 100644 app/models/__init__.py
create mode 100644 app/models/admin.py
create mode 100644 app/models/automation.py
create mode 100644 app/models/base.py
create mode 100644 app/models/enums.py
create mode 100644 app/models/msg.py
create mode 100644 app/models/weixin.py
create mode 100644 app/resources/.gitignore
create mode 100644 app/resources/20260616154526_多淘印6.16_解析_横向排列_1.xlsx
create mode 100644 app/resources/20260623112828_1_解析_横向排列_1.xlsx
create mode 100644 app/resources/models/定制印刷品-订单描述/best_model/config.cfg
create mode 100644 app/resources/models/定制印刷品-订单描述/best_model/meta.json
create mode 100644 app/resources/models/定制印刷品-订单描述/best_model/ner/cfg
create mode 100644 app/resources/models/定制印刷品-订单描述/best_model/ner/model
create mode 100644 app/resources/models/定制印刷品-订单描述/best_model/ner/moves
create mode 100644 app/resources/models/定制印刷品-订单描述/best_model/tokenizer/cfg
create mode 100644 app/resources/models/定制印刷品-订单描述/best_model/vocab/key2row
create mode 100644 app/resources/models/定制印刷品-订单描述/best_model/vocab/lookups.bin
create mode 100644 app/resources/models/定制印刷品-订单描述/best_model/vocab/strings.json
create mode 100644 app/resources/models/定制印刷品-订单描述/best_model/vocab/vectors
create mode 100644 app/resources/models/定制印刷品-订单描述/best_model/vocab/vectors.cfg
create mode 100644 app/resources/models/订单尺寸识别/best_model/config.cfg
create mode 100644 app/resources/models/订单尺寸识别/best_model/meta.json
create mode 100644 app/resources/models/订单尺寸识别/best_model/ner/cfg
create mode 100644 app/resources/models/订单尺寸识别/best_model/ner/model
create mode 100644 app/resources/models/订单尺寸识别/best_model/ner/moves
create mode 100644 app/resources/models/订单尺寸识别/best_model/tokenizer/cfg
create mode 100644 app/resources/models/订单尺寸识别/best_model/vocab/key2row
create mode 100644 app/resources/models/订单尺寸识别/best_model/vocab/lookups.bin
create mode 100644 app/resources/models/订单尺寸识别/best_model/vocab/strings.json
create mode 100644 app/resources/models/订单尺寸识别/best_model/vocab/vectors
create mode 100644 app/resources/models/订单尺寸识别/best_model/vocab/vectors.cfg
create mode 100644 app/schemas/__init__.py
create mode 100644 app/schemas/apis.py
create mode 100644 app/schemas/automation.py
create mode 100644 app/schemas/base.py
create mode 100644 app/schemas/codegen.py
create mode 100644 app/schemas/crm.py
create mode 100644 app/schemas/customer.py
create mode 100644 app/schemas/depts.py
create mode 100644 app/schemas/login.py
create mode 100644 app/schemas/menus.py
create mode 100644 app/schemas/msg.py
create mode 100644 app/schemas/query.py
create mode 100644 app/schemas/roles.py
create mode 100644 app/schemas/task.py
create mode 100644 app/schemas/users.py
create mode 100644 app/schemas/weixin.py
create mode 100644 app/settings/__init__.py
create mode 100644 app/settings/config.py
create mode 100644 app/utils/codegen.py
create mode 100644 app/utils/common.py
create mode 100644 app/utils/db.py
create mode 100644 app/utils/event_task.py
create mode 100644 app/utils/excel_utils.py
create mode 100644 app/utils/jwt.py
create mode 100644 app/utils/password.py
create mode 160000 app/utils/some_sdk
create mode 100644 codegen/.env.development
create mode 100644 codegen/.env.production
create mode 100644 codegen/.eslintignore
create mode 100644 codegen/.eslintrc.cjs
create mode 100644 codegen/.gitignore
create mode 100644 codegen/.npmrc
create mode 100644 codegen/.prettierrc.cjs
create mode 100644 codegen/README.md
create mode 100644 codegen/build/config/define.js
create mode 100644 codegen/build/config/index.js
create mode 100644 codegen/build/constant.js
create mode 100644 codegen/build/plugin/html.js
create mode 100644 codegen/build/plugin/index.js
create mode 100644 codegen/build/plugin/unplugin.js
create mode 100644 codegen/build/script/build-cname.js
create mode 100644 codegen/build/script/index.js
create mode 100644 codegen/build/utils.js
create mode 100644 codegen/components.d.ts
create mode 100644 codegen/index.html
create mode 100644 codegen/package.json
create mode 100644 codegen/public/favicon.ico
create mode 100644 codegen/src/App.vue
create mode 100644 codegen/src/api/baseClass.ts
create mode 100644 codegen/src/api/datasource.ts
create mode 100644 codegen/src/api/fieldType.ts
create mode 100644 codegen/src/api/generator.ts
create mode 100644 codegen/src/api/project.ts
create mode 100644 codegen/src/api/table.ts
create mode 100644 codegen/src/assets/404.png
create mode 100644 codegen/src/components/codemirror/CodeMirror.vue
create mode 100644 codegen/src/components/svg-icon/index.ts
create mode 100644 codegen/src/components/svg-icon/src/svg-icon.vue
create mode 100644 codegen/src/hooks/index.ts
create mode 100644 codegen/src/hooks/interface.ts
create mode 100644 codegen/src/icons/iconfont/iconfont.js
create mode 100644 codegen/src/icons/svg/404.svg
create mode 100644 codegen/src/icons/svg/gitee.svg
create mode 100644 codegen/src/layout/components/Navbar/components/Logo.vue
create mode 100644 codegen/src/layout/components/Navbar/index.vue
create mode 100644 codegen/src/layout/components/Router/Redirect.vue
create mode 100644 codegen/src/layout/components/Sidebar/components/Menu.vue
create mode 100644 codegen/src/layout/components/Sidebar/components/MenuItem.vue
create mode 100644 codegen/src/layout/components/Sidebar/index.vue
create mode 100644 codegen/src/layout/index.vue
create mode 100644 codegen/src/main.ts
create mode 100644 codegen/src/router/index.ts
create mode 100644 codegen/src/styles/element.scss
create mode 100644 codegen/src/styles/index.scss
create mode 100644 codegen/src/styles/layout.scss
create mode 100644 codegen/src/styles/variables.scss
create mode 100644 codegen/src/types/shims.d.ts
create mode 100644 codegen/src/utils/request.ts
create mode 100644 codegen/src/utils/token.js
create mode 100644 codegen/src/utils/tool.ts
create mode 100644 codegen/src/views/404.vue
create mode 100644 codegen/src/views/base-class/add-or-update.vue
create mode 100644 codegen/src/views/base-class/index.vue
create mode 100644 codegen/src/views/datasource/add-or-update.vue
create mode 100644 codegen/src/views/datasource/index.vue
create mode 100644 codegen/src/views/field-type/add-or-update.vue
create mode 100644 codegen/src/views/field-type/index.vue
create mode 100644 codegen/src/views/generator/edit.vue
create mode 100644 codegen/src/views/generator/generator.vue
create mode 100644 codegen/src/views/generator/import.vue
create mode 100644 codegen/src/views/generator/index.vue
create mode 100644 codegen/src/views/generator/preview.vue
create mode 100644 codegen/src/views/project/add-or-update.vue
create mode 100644 codegen/src/views/project/download.vue
create mode 100644 codegen/src/views/project/index.vue
create mode 100644 codegen/tsconfig.json
create mode 100644 codegen/vite.config.ts
create mode 100644 deploy/entrypoint.sh
create mode 100644 deploy/sample-picture/1.jpg
create mode 100644 deploy/sample-picture/2.jpg
create mode 100644 deploy/sample-picture/3.jpg
create mode 100644 deploy/sample-picture/api.jpg
create mode 100644 deploy/sample-picture/group.jpg
create mode 100644 deploy/sample-picture/login.jpg
create mode 100644 deploy/sample-picture/logo.svg
create mode 100644 deploy/sample-picture/menu.jpg
create mode 100644 deploy/sample-picture/role.jpg
create mode 100644 deploy/sample-picture/user.jpg
create mode 100644 deploy/sample-picture/workbench.jpg
create mode 100644 deploy/web.conf
create mode 100644 docker-compose.yml
create mode 100644 pyproject.toml
create mode 100644 requirements.txt
create mode 100644 run.py
create mode 100644 scripts/build.sh
create mode 100644 scripts/build_and_sign.py
create mode 100644 scripts/gen.sh
create mode 100644 scripts/generate_pyi.py
create mode 100644 scripts/setup_example.sh
create mode 100644 scripts/tranform_to_ico.py
create mode 100644 scripts/venv.sh
create mode 100644 static/swagger-ui/swagger-ui-bundle.js
create mode 100644 static/swagger-ui/swagger-ui.css
create mode 100644 templates/relation-demo/app/api/v1/demo/__init__.py.mako
create mode 100644 templates/relation-demo/app/api/v1/demo/demo.py.mako
create mode 100644 templates/relation-demo/model/demo.py.mako
create mode 100644 templates/relation-demo/schemas/demo.py.mako
create mode 100644 templates/relation-demo/tests/test_demo.py.mako
create mode 100644 templates/relation-demo/web/src/components/demo.vue.mako
create mode 100644 templates/vue-fastapi-admin/app/api/v1/demo/__init__.py.mako
create mode 100644 templates/vue-fastapi-admin/app/api/v1/demo/demo.py.mako
create mode 100644 templates/vue-fastapi-admin/model/crud.py.mako
create mode 100644 templates/vue-fastapi-admin/model/demo.py.mako
create mode 100644 templates/vue-fastapi-admin/model/model.py.mako
create mode 100644 templates/vue-fastapi-admin/schemas/demo.py.mako
create mode 100644 templates/vue-fastapi-admin/tests/test_demo.py.mako
create mode 100644 templates/vue-fastapi-admin/web/src/components/demo.vue.mako
create mode 100644 uv.lock
create mode 100644 web/.env
create mode 100644 web/.env.development
create mode 100644 web/.env.production
create mode 100644 web/.eslint-global-variables.json
create mode 100644 web/.eslintignore
create mode 100644 web/.gitignore
create mode 100644 web/.prettierignore
create mode 100644 web/.prettierrc.json
create mode 100644 web/README.md
create mode 100644 web/build/config/define.js
create mode 100644 web/build/config/index.js
create mode 100644 web/build/constant.js
create mode 100644 web/build/plugin/html.js
create mode 100644 web/build/plugin/index.js
create mode 100644 web/build/plugin/unplugin.js
create mode 100644 web/build/script/build-cname.js
create mode 100644 web/build/script/index.js
create mode 100644 web/build/utils.js
create mode 100644 web/i18n/index.js
create mode 100644 web/i18n/messages/cn.json
create mode 100644 web/i18n/messages/en.json
create mode 100644 web/i18n/messages/index.js
create mode 100644 web/index.html
create mode 100644 web/jsconfig.json
create mode 100644 web/package.json
create mode 100644 web/pnpm-lock.yaml
create mode 100644 web/public/favicon.svg
create mode 100644 web/public/resource/loading.css
create mode 100644 web/public/resource/loading.js
create mode 100644 web/settings/index.js
create mode 100644 web/settings/theme.json
create mode 100644 web/src/App.vue
create mode 100644 web/src/api/finance_parse.js
create mode 100644 web/src/api/index.js
create mode 100644 web/src/assets/images/login_bg.webp
create mode 100644 web/src/assets/js/icons.js
create mode 100644 web/src/assets/svg/forbidden.svg
create mode 100644 web/src/assets/svg/front-page.svg
create mode 100644 web/src/assets/svg/logo.svg
create mode 100644 web/src/assets/svg/network-error.svg
create mode 100644 web/src/assets/svg/no-data.svg
create mode 100644 web/src/assets/svg/not-found.svg
create mode 100644 web/src/assets/svg/server-error.svg
create mode 100644 web/src/assets/svg/service-unavailable.svg
create mode 100644 web/src/assets/svg/unauthorized.svg
create mode 100644 web/src/components/common/AppFooter.vue
create mode 100644 web/src/components/common/AppProvider.vue
create mode 100644 web/src/components/common/LoadingEmptyWrapper.vue
create mode 100644 web/src/components/common/ScrollX.vue
create mode 100644 web/src/components/icon/CustomIcon.vue
create mode 100644 web/src/components/icon/IconPicker.vue
create mode 100644 web/src/components/icon/SvgIcon.vue
create mode 100644 web/src/components/icon/TheIcon.vue
create mode 100644 web/src/components/page/AppPage.vue
create mode 100644 web/src/components/page/CommonPage.vue
create mode 100644 web/src/components/query-bar/QueryBar.vue
create mode 100644 web/src/components/query-bar/QueryBarItem.vue
create mode 100644 web/src/components/table/CrudModal.vue
create mode 100644 web/src/components/table/CrudTable.vue
create mode 100644 web/src/composables/index.js
create mode 100644 web/src/composables/useCRUD.js
create mode 100644 web/src/directives/index.js
create mode 100644 web/src/directives/permission.js
create mode 100644 web/src/layout/components/AppMain.vue
create mode 100644 web/src/layout/components/header/components/BreadCrumb.vue
create mode 100644 web/src/layout/components/header/components/FullScreen.vue
create mode 100644 web/src/layout/components/header/components/GithubSite.vue
create mode 100644 web/src/layout/components/header/components/Languages.vue
create mode 100644 web/src/layout/components/header/components/MenuCollapse.vue
create mode 100644 web/src/layout/components/header/components/ThemeMode.vue
create mode 100644 web/src/layout/components/header/components/UserAvatar.vue
create mode 100644 web/src/layout/components/header/index.vue
create mode 100644 web/src/layout/components/sidebar/components/SideLogo.vue
create mode 100644 web/src/layout/components/sidebar/components/SideMenu.vue
create mode 100644 web/src/layout/components/sidebar/index.vue
create mode 100644 web/src/layout/components/tags/ContextMenu.vue
create mode 100644 web/src/layout/components/tags/index.vue
create mode 100644 web/src/layout/index.vue
create mode 100644 web/src/main.js
create mode 100644 web/src/router/guard/auth-guard.js
create mode 100644 web/src/router/guard/index.js
create mode 100644 web/src/router/guard/page-loading-guard.js
create mode 100644 web/src/router/guard/page-title-guard.js
create mode 100644 web/src/router/index.js
create mode 100644 web/src/router/routes/index.js
create mode 100644 web/src/store/index.js
create mode 100644 web/src/store/modules/app/index.js
create mode 100644 web/src/store/modules/index.js
create mode 100644 web/src/store/modules/permission/index.js
create mode 100644 web/src/store/modules/tags/helpers.js
create mode 100644 web/src/store/modules/tags/index.js
create mode 100644 web/src/store/modules/user/index.js
create mode 100644 web/src/styles/global.scss
create mode 100644 web/src/styles/reset.css
create mode 100644 web/src/utils/auth/auth.js
create mode 100644 web/src/utils/auth/index.js
create mode 100644 web/src/utils/auth/token.js
create mode 100644 web/src/utils/common/common.js
create mode 100644 web/src/utils/common/icon.js
create mode 100644 web/src/utils/common/index.js
create mode 100644 web/src/utils/common/is.js
create mode 100644 web/src/utils/common/naiveTools.js
create mode 100644 web/src/utils/common/useResize.js
create mode 100644 web/src/utils/http/helpers.js
create mode 100644 web/src/utils/http/index.js
create mode 100644 web/src/utils/http/interceptors.js
create mode 100644 web/src/utils/index.js
create mode 100644 web/src/utils/storage/index.js
create mode 100644 web/src/utils/storage/storage.js
create mode 100644 web/src/views/erp-dataparse/index.vue
create mode 100644 web/src/views/error-page/401.vue
create mode 100644 web/src/views/error-page/403.vue
create mode 100644 web/src/views/error-page/404.vue
create mode 100644 web/src/views/error-page/500.vue
create mode 100644 web/src/views/login/index.vue
create mode 100644 web/src/views/profile/index.vue
create mode 100644 web/src/views/system/api/index.vue
create mode 100644 web/src/views/system/auditlog/index.vue
create mode 100644 web/src/views/system/dept/index.vue
create mode 100644 web/src/views/system/menu/index.vue
create mode 100644 web/src/views/system/role/index.vue
create mode 100644 web/src/views/system/user/index.vue
create mode 100644 web/src/views/workbench/index.vue
create mode 100644 web/unocss.config.js
create mode 100644 web/vite.config.js
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..38daf51
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,46 @@
+# 版本控制
+.git
+.gitignore
+
+# 编辑器
+.vscode
+
+# 构建与配置文件(非运行时必需)
+Dockerfile
+docker-compose.yml
+README.md
+*.md
+*.log
+*.log.*
+
+# 开发依赖
+web/node_modules
+node_modules
+
+# Python 缓存与临时文件
+__pycache__/
+*.pyc
+*.pyo
+*.pyd
+.pytest_cache/
+.coverage
+htmlcov/
+
+# 测试
+tests/
+test_*.py
+
+# 环境文件(通过 volume 挂载,不进镜像)
+.env
+.env.*
+env/
+
+# 部署脚本(只拷贝需要的 entrypoint.sh,其他 deploy/ 内容不进)
+deploy/
+!deploy/entrypoint.sh
+!deploy/web.conf
+
+# 其他
+*.swp
+*.tmp
+Thumbs.db
\ No newline at end of file
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..07ffcb8
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+*.html linguist-language=python
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f2f858b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,35 @@
+__pycache__/
+.idea/
+venv/
+.mypy_cache/
+.vscode
+.ruff_cache/
+.pytest_cache/
+migrations/
+
+db*.sqlite3
+db.sqlite3
+db.sqlite3-journal
+db.sqlite3-shm
+db.sqlite3-wal
+
+.DS_Store
+._.DS_Store
+
+
+*.zip
+*.tar.gz
+tmp*
+tmp/*
+*.sqlite3-shm
+*.sqlite3-wal
+outputs/
+uploads/
+
+
+env/*
+!env/*example.py
+*.pkl
+scripts/setup.sh
+*.log
+*.log.*
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..e7e47f9
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,63 @@
+FROM node:18.12.0-alpine3.16 AS web
+
+WORKDIR /opt/build
+COPY /web ./web
+RUN npm install pnpm -g --registry=https://registry.npmmirror.com && cd /opt/build/web && pnpm i --registry=https://registry.npmmirror.com && pnpm run build
+
+# === Python 依赖构建阶段(带编译工具)===
+FROM python:3.11-slim-bullseye AS builder
+
+# 切换为中科大源
+RUN sed -i "s@http://.*.debian.org@http://mirrors.ustc.edu.cn@g" /etc/apt/sources.list \
+ && rm -f /etc/apt/apt.conf.d/docker-clean \
+ && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
+ && apt-get update \
+ && apt-get install -y --no-install-recommends gcc python3-dev \
+ && rm -rf /var/lib/apt/lists/*
+
+# 创建虚拟环境
+RUN python -m venv /opt/venv
+ENV PATH="/opt/venv/bin:$PATH"
+
+# 安装 Python 依赖(使用阿里云源,避免清华限流)
+ADD app ./app
+COPY requirements.txt ./app
+RUN find app -name "requirement*.txt" | xargs -n 1 pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ -r
+
+FROM python:3.11-slim-bullseye
+WORKDIR /opt/backend-server
+
+# 设置时区 & 安装最小运行依赖
+RUN sed -i 's@http://.*.debian.org@http://mirrors.ustc.edu.cn@g' /etc/apt/sources.list \
+ && apt-get update \
+ && apt-get install -y --no-install-recommends nginx tzdata bash curl \
+ && rm -rf /var/lib/apt/lists/* \
+ && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
+ && echo "Asia/Shanghai" > /etc/timezone
+
+# 从 builder 阶段拷贝已安装的 Python 依赖
+COPY --from=builder /opt/venv /opt/venv
+
+# 激活虚拟环境(通过 PATH)
+ENV PATH="/opt/venv/bin:$PATH"
+ENV LANG=zh_CN.UTF-8
+
+# 拷贝应用代码(只拷贝必需目录和文件)
+COPY app ./app
+COPY static ./static
+COPY templates ./templates
+COPY run.py .
+COPY deploy/entrypoint.sh .
+
+# 拷贝前端产物
+COPY --from=web /opt/build/web/dist ./web/dist
+
+# Nginx 配置
+COPY deploy/web.conf /etc/nginx/sites-available/web.conf
+RUN rm -f /etc/nginx/sites-enabled/default \
+ && ln -s /etc/nginx/sites-available/web.conf /etc/nginx/sites-enabled/web.conf; \
+ python -c "import sys; print(sys.path)"
+
+EXPOSE 80
+
+ENTRYPOINT ["sh", "entrypoint.sh"]
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..3947bd9
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 mizhexiaoxiao
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..3e32674
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,87 @@
+# Build configuration
+# -------------------
+
+APP_NAME := `sed -n 's/^ *name.*=.*"\([^"]*\)".*/\1/p' pyproject.toml`
+APP_VERSION := `sed -n 's/^ *version.*=.*"\([^"]*\)".*/\1/p' pyproject.toml`
+GIT_REVISION = `git rev-parse HEAD`
+
+# Introspection targets
+# ---------------------
+
+.PHONY: help
+help: header targets
+
+.PHONY: header
+header:
+ @echo "\033[34mEnvironment\033[0m"
+ @echo "\033[34m---------------------------------------------------------------\033[0m"
+ @printf "\033[33m%-23s\033[0m" "APP_NAME"
+ @printf "\033[35m%s\033[0m" $(APP_NAME)
+ @echo ""
+ @printf "\033[33m%-23s\033[0m" "APP_VERSION"
+ @printf "\033[35m%s\033[0m" $(APP_VERSION)
+ @echo ""
+ @printf "\033[33m%-23s\033[0m" "GIT_REVISION"
+ @printf "\033[35m%s\033[0m" $(GIT_REVISION)
+ @echo "\n"
+
+.PHONY: targets
+targets:
+ @echo "\033[34mDevelopment Targets\033[0m"
+ @echo "\033[34m---------------------------------------------------------------\033[0m"
+ @perl -nle'print $& if m{^[a-zA-Z_-]+:.*?## .*$$}' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-22s\033[0m %s\n", $$1, $$2}'
+
+# Development targets
+# -------------
+
+.PHONY: install
+install: ## Install dependencies
+ uv add pyproject.toml
+
+
+.PHONY: run
+run: start
+
+.PHONY: start
+start: ## Starts the server
+ python run.py
+
+# Check, lint and format targets
+# ------------------------------
+
+.PHONY: check
+check: check-format lint
+
+.PHONY: check-format
+check-format: ## Dry-run code formatter
+ black ./ --check
+ isort ./ --profile black --check
+
+.PHONY: lint
+lint: ## Run ruff
+ ruff check ./app
+
+.PHONY: format
+format: ## Run code formatter
+ black ./
+ isort ./ --profile black
+
+
+.PHONY: test
+test: ## Run the test suite
+ $(eval include .env)
+ $(eval export $(sh sed 's/=.*//' .env))
+ pytest -vv -s --cache-clear ./
+
+.PHONY: clean-db
+clean-db: ## 删除migrations文件夹和db.sqlite3
+ find . -type d -name "migrations" -exec rm -rf {} +
+ rm -f db.sqlite3 db.sqlite3-shm db.sqlite3-wal
+
+.PHONY: migrate
+migrate: ## 运行aerich migrate命令生成迁移文件
+ aerich migrate
+
+.PHONY: upgrade
+upgrade: ## 运行aerich upgrade命令应用迁移
+ aerich upgrade
\ No newline at end of file
diff --git a/README-en.md b/README-en.md
new file mode 100644
index 0000000..20a0c84
--- /dev/null
+++ b/README-en.md
@@ -0,0 +1,219 @@
+
+
+
+
+
+
+vue-fastapi-admin
+
+English | [简体中文](./README.md)
+
+vue-fastapi-admin is a modern front-end and back-end separation development platform that combines FastAPI, Vue3, and Naive UI. It incorporates RBAC (Role-Based Access Control) management, dynamic routing, and JWT (JSON Web Token) authentication, making it ideal for rapid development of small to medium-sized applications and also serves as a valuable learning resource.
+
+### Features
+- **Popular Tech Stack**: The backend is developed with the high-performance asynchronous framework FastAPI using Python 3.11, while the front-end is powered by cutting-edge technologies such as Vue3 and Vite, complemented by the efficient package manager, pnpm.
+- **Code Standards**: The project is equipped with various plugins for code standardization and quality control, ensuring consistency and enhancing team collaboration efficiency.
+- **Dynamic Routing**: Backend dynamic routing combined with the RBAC model allows for fine-grained control of menus and routing.
+- **JWT Authentication**: User identity verification and authorization are handled through JWT, enhancing the application's security.
+- **Granular Permission Control**: Implements detailed permission management including button and interface level controls, ensuring different roles and users have appropriate permissions.
+
+### Live Demo
+- URL: http://139.9.100.77:9999
+- Username: admin
+- Password: 123456
+
+### Screenshots
+
+#### Login Page
+
+
+#### Workbench
+
+
+#### User Management
+
+
+#### Role Management
+
+
+#### Menu Management
+
+
+#### API Management
+
+
+### Quick Start
+Please follow the instructions below for installation and configuration:
+
+#### Method 1:dockerhub pull image
+
+```sh
+docker pull mizhexiaoxiao/vue-fastapi-admin:latest
+docker run -d --restart=always --name=vue-fastapi-admin -p 9999:80 mizhexiaoxiao/vue-fastapi-admin
+```
+
+#### Method 2: Build Image Using Dockerfile
+##### Install Docker
+
+```sh
+yum install -y docker-ce
+systemctl start docker
+```
+
+##### Build the Image
+
+```sh
+git clone https://github.com/mizhexiaoxiao/vue-fastapi-admin.git
+cd vue-fastapi-admin
+docker build --no-cache . -t vue-fastapi-admin
+```
+
+##### Start the Container
+
+```sh
+docker run -d --restart=always --name=vue-fastapi-admin -p 9999:80 vue-fastapi-admin
+```
+
+##### Access the Service
+
+http://localhost:9999
+
+username:admin
+
+password:123456
+
+### Local Setup
+#### Backend
+The backend service requires the following environment:
+- Python 3.11
+
+#### Method 1 (Recommended): Install Dependencies with uv
+1. Install uv
+```sh
+pip install uv
+```
+
+2. Create and activate virtual environment
+```sh
+uv venv
+source .venv/bin/activate # Linux/Mac
+# or
+.\.venv\Scripts\activate # Windows
+```
+
+3. Install dependencies
+```sh
+uv add pyproject.toml
+```
+
+4. Start the backend service
+```sh
+python run.py
+```
+
+#### Method 2: Install Dependencies with Pip
+1. Create a Python virtual environment:
+```sh
+python3 -m venv venv
+source venv/bin/activate # Linux/Mac
+# or
+.\venv\Scripts\activate # Windows
+```
+
+2. Install project dependencies:
+```sh
+pip install -r requirements.txt
+```
+
+3. Start the backend service:
+```sh
+python run.py
+```
+The backend service is now running, and you can visit http://localhost:9999/docs to view the API documentation.
+
+#### Frontend
+The frontend project requires a Node.js environment (recommended version 18.8.0 or higher).
+- node v18.8.0+
+
+1. Navigate to the frontend project directory:
+```sh
+cd web
+```
+
+2. Install project dependencies (pnpm is recommended: https://pnpm.io/zh/installation)
+```sh
+npm i -g pnpm # If pnpm is already installed, skip this step
+pnpm i # Or use npm i
+```
+
+3. Start the frontend development server:
+```sh
+pnpm dev
+```
+
+### Directory Structure Explanation
+
+```
+├── app // Application directory
+│ ├── api // API interface directory
+│ │ └── v1 // Version 1 of the API interfaces
+│ │ ├── apis // API-related interfaces
+│ │ ├── base // Base information interfaces
+│ │ ├── menus // Menu related interfaces
+│ │ ├── roles // Role related interfaces
+│ │ └── users // User related interfaces
+│ ├── controllers // Controllers directory
+│ ├── core // Core functionality module
+│ ├── log // Log directory
+│ ├── models // Data models directory
+│ ├── schemas // Data schema/structure definitions
+│ ├── settings // Configuration settings directory
+│ └── utils // Utilities directory
+├── deploy // Deployment related directory
+│ └── sample-picture // Sample picture directory
+└── web // Front-end web directory
+ ├── build // Build scripts and configuration directory
+ │ ├── config // Build configurations
+ │ ├── plugin // Build plugins
+ │ └── script // Build scripts
+ ├── public // Public resources directory
+ │ └── resource // Public resource files
+ ├── settings // Front-end project settings
+ └── src // Source code directory
+ ├── api // API interface definitions
+ ├── assets // Static resources directory
+ │ ├── images // Image resources
+ │ ├── js // JavaScript files
+ │ └── svg // SVG vector files
+ ├── components // Components directory
+ │ ├── common // Common components
+ │ ├── icon // Icon components
+ │ ├── page // Page components
+ │ ├── query-bar // Query bar components
+ │ └── table // Table components
+ ├── composables // Composable functionalities
+ ├── directives // Directives directory
+ ├── layout // Layout directory
+ │ └── components // Layout components
+ ├── router // Routing directory
+ │ ├── guard // Route guards
+ │ └── routes // Route definitions
+ ├── store // State management (pinia)
+ │ └── modules // State modules
+ ├── styles // Style files directory
+ ├── utils // Utilities directory
+ │ ├── auth // Authentication related utilities
+ │ ├── common // Common utilities
+ │ ├── http // Encapsulated axios
+ │ └── storage // Encapsulated localStorage and sessionStorage
+ └── views // Views/Pages directory
+ ├── error-page // Error pages
+ ├── login // Login page
+ ├── profile // Profile page
+ ├── system // System management page
+ └── workbench // Workbench page
+```
+
+### Visitors Count
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e1bff29
--- /dev/null
+++ b/README.md
@@ -0,0 +1,244 @@
+
+
+
+
+
+
+vue-fastapi-admin
+
+[English](./README-en.md) | 简体中文
+
+基于 FastAPI + Vue3 + Naive UI 的现代化前后端分离开发平台,融合了 RBAC 权限管理、动态路由和 JWT 鉴权,助力中小型应用快速搭建,也可用于学习参考。
+
+### 特性
+- **最流行技术栈**:基于 Python 3.11 和 FastAPI 高性能异步框架,结合 Vue3 和 Vite 等前沿技术进行开发,同时使用高效的 npm 包管理器 pnpm。
+- **代码规范**:项目内置丰富的规范插件,确保代码质量和一致性,有效提高团队协作效率。
+- **动态路由**:后端动态路由,结合 RBAC(Role-Based Access Control)权限模型,提供精细的菜单路由控制。
+- **JWT鉴权**:使用 JSON Web Token(JWT)进行身份验证和授权,增强应用的安全性。
+- **细粒度权限控制**:实现按钮和接口级别的权限控制,确保不同用户或角色在界面操作和接口访问时具有不同的权限限制。
+
+### 在线预览
+- http://47.111.145.81:3000
+- username: admin
+- password: 123456
+
+### 登录页
+
+
+### 工作台
+
+
+
+### 用户管理
+
+
+### 角色管理
+
+
+
+### 菜单管理
+
+
+
+### API管理
+
+
+
+### 快速开始
+#### 方法一:dockerhub拉取镜像
+
+```sh
+docker pull mizhexiaoxiao/vue-fastapi-admin:latest
+docker run -d --restart=always --name=vue-fastapi-admin -p 9999:80 mizhexiaoxiao/vue-fastapi-admin
+```
+
+#### 方法二:dockerfile构建镜像
+##### docker安装(版本17.05+)
+
+```sh
+yum install -y docker-ce
+systemctl start docker
+```
+
+##### 构建镜像
+
+```sh
+git clone https://gitee.com/mizhexiaoxiao/vue-fastapi-admin.git
+cd vue-fastapi-admin
+docker build --no-cache . -t vue-fastapi-admin
+```
+
+##### 启动容器
+
+```sh
+docker run -d --restart=always --name=vue-fastapi-admin -p 9999:80 vue-fastapi-admin
+```
+
+##### 访问
+
+http://localhost:9999
+
+username:admin
+
+password:123456
+
+### 本地启动
+#### 后端
+启动项目需要以下环境:
+- Python 3.11
+
+#### 方法一(推荐):使用 uv 安装依赖
+1. 安装 uv
+```sh
+pip install uv
+```
+
+2. 创建并激活虚拟环境
+```sh
+uv venv
+source .venv/bin/activate # Linux/Mac
+# 或
+.\.venv\Scripts\activate # Windows
+```
+
+3. 安装依赖
+```sh
+uv add pyproject.toml
+```
+
+4. 启动服务
+```sh
+python run.py
+```
+
+#### 方法二:使用 Pip 安装依赖
+1. 创建虚拟环境
+```sh
+python3 -m venv venv
+```
+
+2. 激活虚拟环境
+```sh
+source venv/bin/activate # Linux/Mac
+# 或
+.\venv\Scripts\activate # Windows
+```
+
+3. 安装依赖
+```sh
+pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
+```
+
+4. 启动服务
+```sh
+python run.py
+```
+
+服务现在应该正在运行,访问 http://localhost:9999/docs 查看API文档
+
+#### 前端
+启动项目需要以下环境:
+- node v18.8.0+
+
+1. 进入前端目录
+```sh
+cd web
+```
+
+2. 安装依赖(建议使用pnpm: https://pnpm.io/zh/installation)
+```sh
+npm i -g pnpm # 已安装可忽略
+pnpm i # 或者 npm i
+```
+
+3. 启动
+```sh
+pnpm dev
+```
+
+### 目录说明
+
+```
+├── app // 应用程序目录
+│ ├── api // API接口目录
+│ │ └── v1 // 版本1的API接口
+│ │ ├── apis // API相关接口
+│ │ ├── base // 基础信息接口
+│ │ ├── menus // 菜单相关接口
+│ │ ├── roles // 角色相关接口
+│ │ └── users // 用户相关接口
+│ ├── controllers // 控制器目录
+│ ├── core // 核心功能模块
+│ ├── log // 日志目录
+│ ├── models // 数据模型目录
+│ ├── schemas // 数据模式/结构定义
+│ ├── settings // 配置设置目录
+│ └── utils // 工具类目录
+├── deploy // 部署相关目录
+│ └── sample-picture // 示例图片目录
+└── web // 前端网页目录
+ ├── build // 构建脚本和配置目录
+ │ ├── config // 构建配置
+ │ ├── plugin // 构建插件
+ │ └── script // 构建脚本
+ ├── public // 公共资源目录
+ │ └── resource // 公共资源文件
+ ├── settings // 前端项目配置
+ └── src // 源代码目录
+ ├── api // API接口定义
+ ├── assets // 静态资源目录
+ │ ├── images // 图片资源
+ │ ├── js // JavaScript文件
+ │ └── svg // SVG矢量图文件
+ ├── components // 组件目录
+ │ ├── common // 通用组件
+ │ ├── icon // 图标组件
+ │ ├── page // 页面组件
+ │ ├── query-bar // 查询栏组件
+ │ └── table // 表格组件
+ ├── composables // 可组合式功能块
+ ├── directives // 指令目录
+ ├── layout // 布局目录
+ │ └── components // 布局组件
+ ├── router // 路由目录
+ │ ├── guard // 路由守卫
+ │ └── routes // 路由定义
+ ├── store // 状态管理(pinia)
+ │ └── modules // 状态模块
+ ├── styles // 样式文件目录
+ ├── utils // 工具类目录
+ │ ├── auth // 认证相关工具
+ │ ├── common // 通用工具
+ │ ├── http // 封装axios
+ │ └── storage // 封装localStorage和sessionStorage
+ └── views // 视图/页面目录
+ ├── error-page // 错误页面
+ ├── login // 登录页面
+ ├── profile // 个人资料页面
+ ├── system // 系统管理页面
+ └── workbench // 工作台页面
+```
+
+### 进群交流
+进群的条件是给项目一个star,小小的star是作者维护下去的动力。
+
+你可以在群里提出任何疑问,我会尽快回复答疑。
+
+
+
+## 打赏
+如果项目有帮助到你,可以请作者喝杯咖啡~
+
+
+

+

+
+
+## 定制开发
+如果有基于该项目的定制需求或其他合作,请添加下方微信,备注来意
+
+
+
+### Visitors Count
+
+
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 0000000..78f9b04
--- /dev/null
+++ b/app/__init__.py
@@ -0,0 +1,49 @@
+from pathlib import Path
+import sys; sys.path.extend([str(Path(__file__).parent / 'utils' / 'some_sdk')])
+
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI
+from tortoise import Tortoise
+
+from app.core.exceptions import SettingNotFound
+from app.core.init_app import (
+ init_data,
+ tear_down,
+ make_middlewares,
+ register_exceptions,
+ register_routers,
+ mount_static_and_config_swagger
+)
+
+try:
+ from app.settings.config import settings
+except ImportError:
+ raise SettingNotFound("Can not import settings")
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ await init_data()
+ yield
+ await Tortoise.close_connections()
+ await tear_down()
+
+
+def create_app() -> FastAPI:
+ app = FastAPI(
+ title=settings.APP_TITLE,
+ description=settings.APP_DESCRIPTION,
+ version=settings.VERSION,
+ openapi_url="/api/openapi.json",
+ docs_url="/api/api-docs",
+ middleware=make_middlewares(),
+ lifespan=lifespan,
+ )
+ register_exceptions(app)
+ register_routers(app, prefix="/api")
+ mount_static_and_config_swagger(app)
+ return app
+
+
+app = create_app()
diff --git a/app/api/__init__.py b/app/api/__init__.py
new file mode 100644
index 0000000..8ed6454
--- /dev/null
+++ b/app/api/__init__.py
@@ -0,0 +1,9 @@
+from fastapi import APIRouter
+
+from .v1 import v1_router
+
+api_router = APIRouter()
+api_router.include_router(v1_router, prefix="/v1")
+
+
+__all__ = ["api_router"]
diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py
new file mode 100644
index 0000000..4b395f1
--- /dev/null
+++ b/app/api/v1/__init__.py
@@ -0,0 +1,29 @@
+from fastapi import APIRouter
+
+from app.core.dependency import DependPermisson, DependWeixinUser
+
+from .apis import apis_router
+from .auditlog import auditlog_router
+from .base import base_router
+from .depts import depts_router
+from .menus import menus_router
+from .roles import roles_router
+from .users import users_router
+from .weixin import weixin_router
+from .finance_parse import finance_parse_router
+from .msg import msg_route_router
+from .automation import automation_router
+
+v1_router = APIRouter()
+
+v1_router.include_router(base_router, prefix="/base")
+v1_router.include_router(users_router, prefix="/user", dependencies=[DependPermisson])
+v1_router.include_router(roles_router, prefix="/role", dependencies=[DependPermisson])
+v1_router.include_router(menus_router, prefix="/menu", dependencies=[DependPermisson])
+v1_router.include_router(apis_router, prefix="/api", dependencies=[DependPermisson])
+v1_router.include_router(depts_router, prefix="/dept", dependencies=[DependPermisson])
+v1_router.include_router(auditlog_router, prefix="/auditlog", dependencies=[DependPermisson])
+v1_router.include_router(finance_parse_router, prefix="/parse_finance_data", dependencies=[DependPermisson])
+v1_router.include_router(weixin_router, prefix='/weixin')
+v1_router.include_router(automation_router, prefix='/auto')
+v1_router.include_router(msg_route_router, dependencies=[DependWeixinUser])
diff --git a/app/api/v1/apis/__init__.py b/app/api/v1/apis/__init__.py
new file mode 100644
index 0000000..e5435dc
--- /dev/null
+++ b/app/api/v1/apis/__init__.py
@@ -0,0 +1,8 @@
+from fastapi import APIRouter
+
+from .apis import router
+
+apis_router = APIRouter()
+apis_router.include_router(router, tags=["API模块"])
+
+__all__ = ["apis_router"]
diff --git a/app/api/v1/apis/apis.py b/app/api/v1/apis/apis.py
new file mode 100644
index 0000000..9cdca28
--- /dev/null
+++ b/app/api/v1/apis/apis.py
@@ -0,0 +1,67 @@
+from fastapi import APIRouter, Query
+from tortoise.expressions import Q
+
+from app.controllers.api import api_controller
+from app.schemas import Success, SuccessExtra
+from app.schemas.apis import *
+
+router = APIRouter()
+
+
+@router.get("/list", summary="查看API列表")
+async def list_api(
+ page: int = Query(1, description="页码"),
+ page_size: int = Query(10, description="每页数量"),
+ path: str = Query(None, description="API路径"),
+ summary: str = Query(None, description="API简介"),
+ tags: str = Query(None, description="API模块"),
+):
+ q = Q()
+ if path:
+ q &= Q(path__contains=path)
+ if summary:
+ q &= Q(summary__contains=summary)
+ if tags:
+ q &= Q(tags__contains=tags)
+ total, api_objs = await api_controller.list(page=page, page_size=page_size, search=q, order=["tags", "id"])
+ data = [await obj.to_dict() for obj in api_objs]
+ return SuccessExtra(data=data, total=total, page=page, page_size=page_size)
+
+
+@router.get("/get", summary="查看Api")
+async def get_api(
+ id: int = Query(..., description="Api"),
+):
+ api_obj = await api_controller.get(id=id)
+ data = await api_obj.to_dict()
+ return Success(data=data)
+
+
+@router.post("/create", summary="创建Api")
+async def create_api(
+ api_in: ApiCreate,
+):
+ await api_controller.create(obj_in=api_in)
+ return Success(msg="Created Successfully")
+
+
+@router.post("/update", summary="更新Api")
+async def update_api(
+ api_in: ApiUpdate,
+):
+ await api_controller.update(id=api_in.id, obj_in=api_in)
+ return Success(msg="Update Successfully")
+
+
+@router.delete("/delete", summary="删除Api")
+async def delete_api(
+ api_id: int = Query(..., description="ApiID"),
+):
+ await api_controller.remove(id=api_id)
+ return Success(msg="Deleted Success")
+
+
+@router.post("/refresh", summary="刷新API列表")
+async def refresh_api():
+ await api_controller.refresh_api()
+ return Success(msg="OK")
diff --git a/app/api/v1/auditlog/__init__.py b/app/api/v1/auditlog/__init__.py
new file mode 100644
index 0000000..d90f07c
--- /dev/null
+++ b/app/api/v1/auditlog/__init__.py
@@ -0,0 +1,8 @@
+from fastapi import APIRouter
+
+from .auditlog import router
+
+auditlog_router = APIRouter()
+auditlog_router.include_router(router, tags=["审计日志模块"])
+
+__all__ = ["auditlog_router"]
diff --git a/app/api/v1/auditlog/auditlog.py b/app/api/v1/auditlog/auditlog.py
new file mode 100644
index 0000000..ffc7fc6
--- /dev/null
+++ b/app/api/v1/auditlog/auditlog.py
@@ -0,0 +1,44 @@
+from fastapi import APIRouter, Query
+from tortoise.expressions import Q
+
+from app.models.admin import AuditLog
+from app.schemas import SuccessExtra
+from app.schemas.apis import *
+
+router = APIRouter()
+
+
+@router.get("/list", summary="查看操作日志")
+async def get_audit_log_list(
+ page: int = Query(1, description="页码"),
+ page_size: int = Query(10, description="每页数量"),
+ username: str = Query("", description="操作人名称"),
+ module: str = Query("", description="功能模块"),
+ method: str = Query("", description="请求方法"),
+ summary: str = Query("", description="接口描述"),
+ status: int = Query(None, description="状态码"),
+ start_time: str = Query("", description="开始时间"),
+ end_time: str = Query("", description="结束时间"),
+):
+ q = Q()
+ if username:
+ q &= Q(username__icontains=username)
+ if module:
+ q &= Q(module__icontains=module)
+ if method:
+ q &= Q(method__icontains=method)
+ if summary:
+ q &= Q(summary__icontains=summary)
+ if status:
+ q &= Q(status=status)
+ if start_time and end_time:
+ q &= Q(created_at__range=[start_time, end_time])
+ elif start_time:
+ q &= Q(created_at__gte=start_time)
+ elif end_time:
+ q &= Q(created_at__lte=end_time)
+
+ audit_log_objs = await AuditLog.filter(q).offset((page - 1) * page_size).limit(page_size).order_by("-created_at")
+ total = await AuditLog.filter(q).count()
+ data = [await audit_log.to_dict() for audit_log in audit_log_objs]
+ return SuccessExtra(data=data, total=total, page=page, page_size=page_size)
diff --git a/app/api/v1/automation/__init__.py b/app/api/v1/automation/__init__.py
new file mode 100644
index 0000000..f7174f8
--- /dev/null
+++ b/app/api/v1/automation/__init__.py
@@ -0,0 +1,8 @@
+from fastapi import APIRouter
+
+from .automation import router
+
+automation_router = APIRouter()
+automation_router.include_router(router, tags=["自动化模块"])
+
+__all__ = ["automation_router"]
diff --git a/app/api/v1/automation/automation.py b/app/api/v1/automation/automation.py
new file mode 100644
index 0000000..f883a79
--- /dev/null
+++ b/app/api/v1/automation/automation.py
@@ -0,0 +1,188 @@
+# app/api/v1/automation.py
+
+import logging
+from typing import Dict, Any
+from app.http_base import unified_resp
+from fastapi import APIRouter, Query, Depends
+from tortoise.expressions import Q
+from app.controllers.automation.scenario import automation_scenario_controller
+from app.controllers.automation.task import task_controller
+from app.controllers.automation.action import action_controller
+from app.schemas.automation import (
+ ScenarioCreate,
+ ScenarioUpdate,
+ ScenarioCopy,
+ TaskUpdate,
+ ActionUpdate,
+)
+from app.schemas.base import Success, SuccessExtra, Fail
+from app.schemas.apis import Paginate
+from ..weixin.base import get_weixin_user
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+
+# ============================
+# Scenario 路由
+# ============================
+
+@router.get("/scenario/list", summary="查看场景列表")
+@unified_resp
+async def list_automation_scenario(
+ pagination: Paginate = Depends(),
+ title: str = Query("", description="场景标题,用于搜索"),
+ is_global: bool = Query(None, description="是否全局场景"),
+ enabled: bool = Query(None, description="是否启用"),
+ owner_user_id: str = Query("", description="归属用户ID(非全局时)"),
+):
+ q = Q(visible=True)
+ if title:
+ q &= Q(title__contains=title)
+ if is_global is not None:
+ q &= Q(is_global=is_global)
+ if enabled is not None:
+ q &= Q(enabled=enabled)
+ if owner_user_id:
+ q &= Q(owner_user_id=owner_user_id)
+
+ total, scenario_objs = await automation_scenario_controller.list(
+ page=pagination.page, page_size=pagination.page_size, search=q, order=["-created_at"]
+ )
+ data = [await obj.to_dict() for obj in scenario_objs]
+ return {"count": total, "lists": data, "page": pagination.page, "page_size": pagination.page_size}
+
+
+@router.get("/scenario/event_and_action", summary="查看场景事件和动作")
+@unified_resp
+async def list_event_and_action():
+ actions = await action_controller.list_automation_actions()
+ events = await automation_scenario_controller.list_automation_events()
+ return {"events": events, "actions": actions}
+
+
+@router.get("/scenario/get/{id}", summary="查看场景详情")
+@unified_resp
+async def get_automation_scenario(id: int):
+ obj = await automation_scenario_controller.get(id=id)
+ return await obj.to_dict()
+
+
+@router.post("/scenario/create", summary="创建场景")
+@unified_resp
+async def create_automation_scenario(
+ scenario_in: ScenarioCreate,
+ weixin_user: dict = Depends(get_weixin_user)
+):
+ obj = await automation_scenario_controller.new_scenario(scenario_in, weixin_user.userid)
+ return await obj.to_dict()
+
+
+@router.post("/scenario/copy", summary="复制场景")
+@unified_resp
+async def copy_automation_scenario(
+ scenario_in: ScenarioCopy,
+):
+ def handler(obj_dict: Dict[str, Any]) -> Dict[str, Any]:
+ obj_dict["title"] = f"复制:{obj_dict['title']}"
+ obj_dict["enabled"] = False
+ return obj_dict
+
+ obj = await automation_scenario_controller.copy(id=scenario_in.id, handler=handler)
+ return await obj.to_dict()
+
+
+@router.post("/scenario/update", summary="更新场景")
+@unified_resp
+async def update_automation_scenario(
+ scenario_in: ScenarioUpdate,
+ weixin_user: dict = Depends(get_weixin_user)
+):
+ obj = await automation_scenario_controller.update_scenario(scenario_in=scenario_in, user_id=weixin_user.userid)
+ return await obj.to_dict()
+
+
+@router.delete("/scenario/delete", summary="删除场景")
+@unified_resp
+async def delete_automation_scenario(
+ id: int = Query(..., description="场景ID"),
+):
+ await automation_scenario_controller.remove_scenario(scenario_id=id)
+ return Success(msg="Deleted Successfully")
+
+
+# ============================
+# Task 路由
+# ============================
+
+@router.get("/task/list", summary="查看待办列表")
+@unified_resp
+async def list_task(
+ pagination: Paginate = Depends(),
+ assignee_user_id: str = Query("", description="指派人用户ID"),
+ status: str = Query("", description="任务状态"),
+ related_customer_id: str = Query("", description="关联客户ID"),
+):
+ q = Q()
+ if assignee_user_id:
+ q &= Q(assignee_user_id=assignee_user_id)
+ if status:
+ q &= Q(status=status)
+ if related_customer_id:
+ q &= Q(related_customer_id=related_customer_id)
+
+ total, task_objs = await task_controller.list(
+ page=pagination.page, page_size=pagination.page_size, search=q
+ # , order=["-created_at"]
+ )
+ data = [await obj.to_dict() for obj in task_objs]
+ return {"count": total, "lists": data, "page": pagination.page, "page_size": pagination.page_size}
+
+
+@router.get("/task/get", summary="查看待办详情")
+@unified_resp
+async def get_task(
+ id: int = Query(..., description="待办ID"),
+):
+ obj = await task_controller.get(id=id)
+ return await obj.to_dict()
+
+
+@router.post("/task/update", summary="更新待办")
+@unified_resp
+async def update_task(
+ task_in: TaskUpdate,
+):
+ obj = await task_controller.update(id=task_in.id, obj_in=task_in)
+ return await obj.to_dict()
+
+
+# ============================
+# Action 路由
+# ============================
+
+@router.get("/action/list", summary="查看动作列表(按任务)")
+@unified_resp
+async def list_action(
+ task_id: int = Query(..., description="所属任务ID"),
+):
+ actions = await action_controller.model.filter(task_id=task_id).all()
+ data = [await act.to_dict() for act in actions]
+ return data
+
+
+# @router.get("/action/type", summary="查看动作类型列表(按任务)")
+# @unified_resp
+# async def list_action_type():
+# actions = await action_controller.list_automation_actions()
+# return actions
+
+
+@router.post("/action/update", summary="更新动作(如标记完成)")
+@unified_resp
+async def update_action(
+ action_in: ActionUpdate,
+):
+ obj = await action_controller.update(id=action_in.id, obj_in=action_in)
+ return await obj.to_dict()
\ No newline at end of file
diff --git a/app/api/v1/base/__init__.py b/app/api/v1/base/__init__.py
new file mode 100644
index 0000000..bc9b157
--- /dev/null
+++ b/app/api/v1/base/__init__.py
@@ -0,0 +1,8 @@
+from fastapi import APIRouter
+
+from .base import router
+
+base_router = APIRouter()
+base_router.include_router(router, tags=["基础模块"])
+
+__all__ = ["base_router"]
diff --git a/app/api/v1/base/base.py b/app/api/v1/base/base.py
new file mode 100644
index 0000000..3ba5655
--- /dev/null
+++ b/app/api/v1/base/base.py
@@ -0,0 +1,103 @@
+from datetime import datetime, timedelta, timezone
+
+from fastapi import APIRouter
+
+from app.controllers.user import user_controller
+from app.core.ctx import CTX_USER_ID
+from app.core.dependency import DependAuth
+from app.models.admin import Api, Menu, Role, User
+from app.schemas.base import Fail, Success
+from app.schemas.login import *
+from app.schemas.users import UpdatePassword
+from app.settings import settings
+from app.utils.jwt import create_access_token
+from app.utils.password import get_password_hash, verify_password
+
+router = APIRouter()
+
+
+@router.post("/access_token", summary="获取token")
+async def login_access_token(credentials: CredentialsSchema):
+ user: User = await user_controller.authenticate(credentials)
+ await user_controller.update_last_login(user.id)
+ access_token_expires = timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
+ expire = datetime.now(timezone.utc) + access_token_expires
+
+ data = JWTOut(
+ access_token=create_access_token(
+ data=JWTPayload(
+ user_id=user.id,
+ username=user.username,
+ is_superuser=user.is_superuser,
+ exp=expire,
+ )
+ ),
+ username=user.username,
+ )
+ return Success(data=data.model_dump())
+
+
+@router.get("/userinfo", summary="查看用户信息", dependencies=[DependAuth])
+async def get_userinfo():
+ user_id = CTX_USER_ID.get()
+ user_obj = await user_controller.get(id=user_id)
+ data = await user_obj.to_dict(exclude_fields=["password"])
+ data["avatar"] = "https://avatars.githubusercontent.com/u/54677442?v=4"
+ return Success(data=data)
+
+
+@router.get("/usermenu", summary="查看用户菜单", dependencies=[DependAuth])
+async def get_user_menu():
+ user_id = CTX_USER_ID.get()
+ user_obj = await User.filter(id=user_id).first()
+ menus: list[Menu] = []
+ if user_obj.is_superuser:
+ menus = await Menu.all()
+ else:
+ role_objs: list[Role] = await user_obj.roles
+ for role_obj in role_objs:
+ menu = await role_obj.menus
+ menus.extend(menu)
+ menus = list(set(menus))
+ parent_menus: list[Menu] = []
+ for menu in menus:
+ if menu.parent_id == 0:
+ parent_menus.append(menu)
+ res = []
+ for parent_menu in parent_menus:
+ parent_menu_dict = await parent_menu.to_dict()
+ parent_menu_dict["children"] = []
+ for menu in menus:
+ if menu.parent_id == parent_menu.id:
+ parent_menu_dict["children"].append(await menu.to_dict())
+ res.append(parent_menu_dict)
+ return Success(data=res)
+
+
+@router.get("/userapi", summary="查看用户API", dependencies=[DependAuth])
+async def get_user_api():
+ user_id = CTX_USER_ID.get()
+ user_obj = await User.filter(id=user_id).first()
+ if user_obj.is_superuser:
+ api_objs: list[Api] = await Api.all()
+ apis = [api.method.lower() + api.path for api in api_objs]
+ return Success(data=apis)
+ role_objs: list[Role] = await user_obj.roles
+ apis = []
+ for role_obj in role_objs:
+ api_objs: list[Api] = await role_obj.apis
+ apis.extend([api.method.lower() + api.path for api in api_objs])
+ apis = list(set(apis))
+ return Success(data=apis)
+
+
+@router.post("/update_password", summary="修改密码", dependencies=[DependAuth])
+async def update_user_password(req_in: UpdatePassword):
+ user_id = CTX_USER_ID.get()
+ user = await user_controller.get(user_id)
+ verified = verify_password(req_in.old_password, user.password)
+ if not verified:
+ return Fail(msg="旧密码验证错误!")
+ user.password = get_password_hash(req_in.new_password)
+ await user.save()
+ return Success(msg="修改成功")
diff --git a/app/api/v1/codegen/__init__.py b/app/api/v1/codegen/__init__.py
new file mode 100644
index 0000000..bb23c9a
--- /dev/null
+++ b/app/api/v1/codegen/__init__.py
@@ -0,0 +1,10 @@
+from fastapi import APIRouter
+
+from .table import router
+from .datasource import router as datasource_router
+
+codegen_router = APIRouter()
+codegen_router.include_router(router, tags=["代码生成模块"])
+codegen_router.include_router(datasource_router, tags=["数据源管理"])
+
+__all__ = ["codegen_router"]
diff --git a/app/api/v1/codegen/datasource.py b/app/api/v1/codegen/datasource.py
new file mode 100644
index 0000000..910bde3
--- /dev/null
+++ b/app/api/v1/codegen/datasource.py
@@ -0,0 +1,73 @@
+import logging
+
+from fastapi import APIRouter, Query
+from fastapi.exceptions import HTTPException
+from tortoise.expressions import Q
+
+from app.controllers.datasource import datasource_controller
+from app.schemas.base import Success, SuccessExtra
+from app.schemas.roles import *
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/datasource")
+
+
+@router.get("/list", summary="查看模板列表")
+async def list_role(
+ page: int = Query(1, description="页码"),
+ limit: int = Query(10, description="每页数量"),
+ table_name: str = Query("", description="名称,用于查询"),
+):
+ # data = await datasource_controller.load_tables()
+ # return SuccessExtra(data=data)
+ q = Q()
+ if table_name:
+ q = Q(name__contains=table_name)
+ total, role_objs = await datasource_controller.list(page=page, page_size=limit, search=q)
+ data = [await obj.to_dict() for obj in role_objs]
+ return SuccessExtra(data=data, total=total, page=page, page_size=limit)
+
+
+@router.get("/get/{table_name}", summary="查看")
+async def get_role(table_name):
+ data = await datasource_controller.load_tables(name=table_name)
+ return SuccessExtra(data=data)
+
+
+@router.post("/create", summary="创建")
+async def create_role(role_in: RoleCreate):
+ if await datasource_controller.is_exist(name=role_in.name):
+ raise HTTPException(
+ status_code=400,
+ detail="The role with this rolename already exists in the system.",
+ )
+ await datasource_controller.create(obj_in=role_in)
+ return Success(msg="Created Successfully")
+
+
+@router.post("/update", summary="更新")
+async def update_role(role_in: RoleUpdate):
+ await datasource_controller.update(id=role_in.id, obj_in=role_in)
+ return Success(msg="Updated Successfully")
+
+
+@router.delete("/delete", summary="删除")
+async def delete_role(
+ role_id: int = Query(..., description="ID"),
+):
+ await datasource_controller.remove(id=role_id)
+ return Success(msg="Deleted Success")
+
+
+@router.get("/authorized", summary="查看权限")
+async def get_role_authorized(id: int = Query(..., description="ID")):
+ role_obj = await datasource_controller.get(id=id)
+ data = await role_obj.to_dict(m2m=True)
+ return Success(data=data)
+
+
+@router.post("/authorized", summary="更新权限")
+async def update_role_authorized(role_in: RoleUpdateMenusApis):
+ role_obj = await datasource_controller.get(id=role_in.id)
+ await datasource_controller.update_roles(role=role_obj, menu_ids=role_in.menu_ids, api_infos=role_in.api_infos)
+ return Success(msg="Updated Successfully")
diff --git a/app/api/v1/codegen/table.py b/app/api/v1/codegen/table.py
new file mode 100644
index 0000000..2501e4c
--- /dev/null
+++ b/app/api/v1/codegen/table.py
@@ -0,0 +1,81 @@
+import logging
+
+from fastapi import APIRouter, Query
+from fastapi.exceptions import HTTPException
+from tortoise.expressions import Q
+
+from app.controllers.table import codegen_controller
+from app.schemas.base import Success, SuccessExtra
+from app.schemas.codegen import *
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/table")
+
+
+@router.get("/page", summary="查看列表")
+async def list_role(
+ page: int = Query(1, description="页码"),
+ limit: int = Query(10, description="每页数量"),
+ table_name: str = Query("", description="名称,用于查询"),
+):
+ q = Q()
+ if table_name:
+ q = Q(name__contains=table_name)
+ total, role_objs = await codegen_controller.list(page=page, page_size=limit, search=q)
+ data = [await obj.to_dict() for obj in role_objs]
+ return SuccessExtra(data=dict(list=data, total=total, page=page, page_size=limit))
+
+
+@router.post("/import/{db_connection}", summary="导入表")
+async def get_role(db_connection, table: dict = None):
+ print('db_connection, importTables', db_connection, table)
+ data = await codegen_controller.import_table(db_connection, importTables=table['table'])
+ # role_obj = await codegen_controller.get(id=role_id)
+ return Success(data=data)
+
+
+import json
+@router.put("/field/{id}", summary="更新字段")
+async def update_fields(id: int, fields_in: dict):
+ fields_in['fields'] = json.dumps(fields_in['fields'])
+ await codegen_controller.update(id=id, obj_in=fields_in)
+ return Success(msg="Updated Successfully")
+
+@router.get("/preview/{id}", summary="代码预览")
+async def code_preview(id: int):
+ data = await codegen_controller.preview(id)
+ return Success(data=data)
+
+
+@router.get("/{id}", summary="查看详细的表信息")
+async def get_table_detail(id):
+ table_obj = await codegen_controller.get(id=id)
+ return Success(data=await table_obj.to_dict())
+
+@router.post("/update", summary="更新")
+async def update_role(role_in):
+ await codegen_controller.update(id=role_in.id, obj_in=role_in)
+ return Success(msg="Updated Successfully")
+
+
+@router.delete("/", summary="删除")
+async def delete_role(
+ batch_ids: dict
+):
+ for id in batch_ids.get('data'):
+ await codegen_controller.remove(id=id)
+ return Success(msg="Deleted Success")
+
+
+@router.get("/authorized", summary="查看权限")
+async def get_role_authorized(id: int = Query(..., description="ID")):
+ role_obj = await codegen_controller.get(id=id)
+ data = await role_obj.to_dict(m2m=True)
+ return Success(data=data)
+
+
+@router.post("/authorized", summary="更新权限")
+async def update_role_authorized(role_in):
+ role_obj = await codegen_controller.get(id=role_in.id)
+ await codegen_controller.update_roles(role=role_obj, menu_ids=role_in.menu_ids, api_infos=role_in.api_infos)
+ return Success(msg="Updated Successfully")
diff --git a/app/api/v1/depts/__init__.py b/app/api/v1/depts/__init__.py
new file mode 100644
index 0000000..1455b12
--- /dev/null
+++ b/app/api/v1/depts/__init__.py
@@ -0,0 +1,8 @@
+from fastapi import APIRouter
+
+from .depts import router
+
+depts_router = APIRouter()
+depts_router.include_router(router, tags=["部门模块"])
+
+__all__ = ["depts_router"]
diff --git a/app/api/v1/depts/depts.py b/app/api/v1/depts/depts.py
new file mode 100644
index 0000000..a22ef0a
--- /dev/null
+++ b/app/api/v1/depts/depts.py
@@ -0,0 +1,48 @@
+from fastapi import APIRouter, Query
+
+from app.controllers.dept import dept_controller
+from app.schemas import Success
+from app.schemas.depts import *
+
+router = APIRouter()
+
+
+@router.get("/list", summary="查看部门列表")
+async def list_dept(
+ name: str = Query(None, description="部门名称"),
+):
+ dept_tree = await dept_controller.get_dept_tree(name)
+ return Success(data=dept_tree)
+
+
+@router.get("/get", summary="查看部门")
+async def get_dept(
+ id: int = Query(..., description="部门ID"),
+):
+ dept_obj = await dept_controller.get(id=id)
+ data = await dept_obj.to_dict()
+ return Success(data=data)
+
+
+@router.post("/create", summary="创建部门")
+async def create_dept(
+ dept_in: DeptCreate,
+):
+ await dept_controller.create_dept(obj_in=dept_in)
+ return Success(msg="Created Successfully")
+
+
+@router.post("/update", summary="更新部门")
+async def update_dept(
+ dept_in: DeptUpdate,
+):
+ await dept_controller.update_dept(obj_in=dept_in)
+ return Success(msg="Update Successfully")
+
+
+@router.delete("/delete", summary="删除部门")
+async def delete_dept(
+ dept_id: int = Query(..., description="部门ID"),
+):
+ await dept_controller.delete_dept(dept_id=dept_id)
+ return Success(msg="Deleted Success")
diff --git a/app/api/v1/finance_parse/__init__.py b/app/api/v1/finance_parse/__init__.py
new file mode 100644
index 0000000..d46affe
--- /dev/null
+++ b/app/api/v1/finance_parse/__init__.py
@@ -0,0 +1,8 @@
+from fastapi import APIRouter
+
+from .finance_parse import router
+
+finance_parse_router = APIRouter()
+finance_parse_router.include_router(router, tags=["订单备注解析模块"])
+
+__all__ = ["finance_parse_router"]
diff --git a/app/api/v1/finance_parse/finance_parse.py b/app/api/v1/finance_parse/finance_parse.py
new file mode 100644
index 0000000..c191998
--- /dev/null
+++ b/app/api/v1/finance_parse/finance_parse.py
@@ -0,0 +1,95 @@
+from fastapi import APIRouter, File, UploadFile, Query
+from fastapi.responses import FileResponse
+from app.schemas import Success, SuccessExtra, Fail
+from app.controllers.finance_parse import task_controller, parse_finance_data
+import os, time
+from app.schemas.task import DecodeTaskParams, DecodeTaskResult, TaskResponse
+from app.models.automation import TaskStatus, TaskType
+from datetime import datetime
+router = APIRouter()
+
+import shutil
+from pathlib import Path
+
+from app.utils.excel_utils import get_sheets_and_headers
+
+
+@router.post("/upload", summary="上传文件")
+async def upload_file(file: UploadFile = File(...)):
+ # file.filename: 原始文件名
+ # file.content_type: MIME 类型
+ # file.file: 类文件对象(SpooledTemporaryFile)
+
+ # 保存文件到本地(示例:保存到 ./uploads/)
+ upload_dir = Path("uploads")
+ upload_dir.mkdir(exist_ok=True)
+ filename = f"{datetime.now().strftime('%Y%m%d%H%M%S')}_{file.filename}"
+
+ file_path = upload_dir / filename
+ with open(file_path, "wb") as buffer:
+ shutil.copyfileobj(file.file, buffer)
+
+ # 获取所有 sheet 名称及其表头
+ sheets_headers = get_sheets_and_headers(file_path)
+
+ # # 打印结果
+ # for sheet_name, headers in sheets_headers.items():
+ # print(f"Sheet: {sheet_name}")
+ # print(f"Headers: {headers}\n")
+
+ data = {
+ "filename": filename,
+ "data": sheets_headers,
+ }
+ return Success(data=data)
+
+
+@router.post("/parse", summary="解析文件")
+async def parse_file(data: dict):
+ upload_dir = Path("uploads")
+ filename = upload_dir / data["filename"]
+ sheet = data["sheet"]
+ header = data["header"]
+ parse_type = data["parse_type"]
+ taskname = data["filename"] + "_" + sheet
+
+ print(filename, sheet, header, parse_type)
+ task, task_obj = await task_controller.create_task(name=taskname, obj_in=DecodeTaskParams(filename=str(filename), sheet_name=sheet, header=header, decode_type=parse_type))
+ total_amount = 0
+ start = time.time()
+
+ try:
+ result_file_path, total_amount = parse_finance_data(
+ filename,
+ target_index=header,
+ is_horizontal=(parse_type == "horizontal"),
+ sheet_name=sheet
+ )
+ except Exception as e:
+ return Fail(msg=f"解析失败: {str(e)}", code=400)
+
+ if not os.path.exists(result_file_path):
+ return Fail(msg=f"解析结果文件未生成", code=404)
+
+ # 提取原始文件名(不含路径),用于下载时的默认文件名
+ download_filename = os.path.basename(result_file_path)
+ await task_controller.update_task(task_obj, task.id, TaskStatus.SUCCESS, DecodeTaskResult(
+ filename=str(result_file_path),
+ spend=time.time() - start,
+ rows=total_amount,
+ ))
+
+ return FileResponse(
+ path=result_file_path,
+ filename=download_filename, # 浏览器下载时显示的文件名
+ media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' # .xlsx
+ )
+
+@router.get("/list", summary="获取任务列表")
+async def get_tasks(page: int = Query(1, description="页码"),
+ page_size: int = Query(10, description="每页数量"),
+ name: str = Query("", description="任务名称,用于查询"),
+ type: TaskType = Query(None, description="任务类型,用于查询")):
+ total, tasks = await task_controller.list(name, type, page, page_size)
+ data = [TaskResponse.from_orm(task).model_dump() for task in tasks]
+ return SuccessExtra(data=data, total=total, page=page, page_size=page_size)
diff --git a/app/api/v1/menus/__init__.py b/app/api/v1/menus/__init__.py
new file mode 100644
index 0000000..3e27a24
--- /dev/null
+++ b/app/api/v1/menus/__init__.py
@@ -0,0 +1,8 @@
+from fastapi import APIRouter
+
+from .menus import router
+
+menus_router = APIRouter()
+menus_router.include_router(router, tags=["菜单模块"])
+
+__all__ = ["menus_router"]
diff --git a/app/api/v1/menus/menus.py b/app/api/v1/menus/menus.py
new file mode 100644
index 0000000..308e8ca
--- /dev/null
+++ b/app/api/v1/menus/menus.py
@@ -0,0 +1,63 @@
+import logging
+
+from fastapi import APIRouter, Query
+
+from app.controllers.menu import menu_controller
+from app.schemas.base import Fail, Success, SuccessExtra
+from app.schemas.menus import *
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+
+@router.get("/list", summary="查看菜单列表")
+async def list_menu(
+ page: int = Query(1, description="页码"),
+ page_size: int = Query(10, description="每页数量"),
+):
+ async def get_menu_with_children(menu_id: int):
+ menu = await menu_controller.model.get(id=menu_id)
+ menu_dict = await menu.to_dict()
+ child_menus = await menu_controller.model.filter(parent_id=menu_id).order_by("order")
+ menu_dict["children"] = [await get_menu_with_children(child.id) for child in child_menus]
+ return menu_dict
+
+ parent_menus = await menu_controller.model.filter(parent_id=0).order_by("order")
+ res_menu = [await get_menu_with_children(menu.id) for menu in parent_menus]
+ return SuccessExtra(data=res_menu, total=len(res_menu), page=page, page_size=page_size)
+
+
+@router.get("/get", summary="查看菜单")
+async def get_menu(
+ menu_id: int = Query(..., description="菜单id"),
+):
+ result = await menu_controller.get(id=menu_id)
+ return Success(data=result)
+
+
+@router.post("/create", summary="创建菜单")
+async def create_menu(
+ menu_in: MenuCreate,
+):
+ await menu_controller.create(obj_in=menu_in)
+ return Success(msg="Created Success")
+
+
+@router.post("/update", summary="更新菜单")
+async def update_menu(
+ menu_in: MenuUpdate,
+):
+ await menu_controller.update(id=menu_in.id, obj_in=menu_in)
+ return Success(msg="Updated Success")
+
+
+@router.delete("/delete", summary="删除菜单")
+async def delete_menu(
+ id: int = Query(..., description="菜单id"),
+):
+ child_menu_count = await menu_controller.model.filter(parent_id=id).count()
+ if child_menu_count > 0:
+ return Fail(msg="Cannot delete a menu with child menus")
+ await menu_controller.remove(id=id)
+ return Success(msg="Deleted Success")
diff --git a/app/api/v1/msg/__init__.py b/app/api/v1/msg/__init__.py
new file mode 100644
index 0000000..e6f5d04
--- /dev/null
+++ b/app/api/v1/msg/__init__.py
@@ -0,0 +1,9 @@
+from fastapi import APIRouter
+from .msg import router as msg_router
+from .event import router as event_router
+
+msg_route_router = APIRouter()
+msg_route_router.include_router(msg_router, tags=["消息推送模块"])
+msg_route_router.include_router(event_router, tags=["事件模块"])
+
+__all__ = ["msg_route_router"]
diff --git a/app/api/v1/msg/event.py b/app/api/v1/msg/event.py
new file mode 100644
index 0000000..4854be3
--- /dev/null
+++ b/app/api/v1/msg/event.py
@@ -0,0 +1,15 @@
+# app/api/wechat.py
+from fastapi import APIRouter
+from app.schemas.msg import FeishuEvent
+from app.http_base import unified_resp
+
+import logging
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/event")
+
+# 群聊入口
+@router.post("/feishu", summary="通过群ID获取到群信息以及同步数据到本地")
+async def feishu(feishuEvent: FeishuEvent):
+ return {"challenge": feishuEvent.challenge}
+
diff --git a/app/api/v1/msg/msg.py b/app/api/v1/msg/msg.py
new file mode 100644
index 0000000..6168014
--- /dev/null
+++ b/app/api/v1/msg/msg.py
@@ -0,0 +1,80 @@
+# app/api/wechat.py
+from fastapi import APIRouter, Depends
+from app.controllers.msg import msg_controller
+from app.controllers.weixin.customer import weixin_customer_controller
+from app.schemas.msg import MsgNewOrder, MsgFilter, MsgUpdate
+from app.http_base import unified_resp
+from app.schemas.apis import Paginate
+from tortoise.expressions import Q
+from ..weixin.base import get_weixin_user
+
+import logging
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix='/msg')
+
+# 群聊入口
+@router.post("/new_order", summary="通过群ID获取到群信息以及同步数据到本地")
+@unified_resp
+async def create_new_order(msg: MsgNewOrder):
+
+ if msg.buyer_id.startswith('*******'):
+ return {"msg": "不支持的客户类型"}
+
+ if msg.buyer_nick:
+ weixin_customer = await weixin_customer_controller.model.filter(taobao_name=msg.buyer_nick).first()
+ elif msg.buyer_id:
+ weixin_customer = await weixin_customer_controller.model.filter(weixin_id=msg.buyer_id).first()
+ else:
+ return {"msg": "buyer_id or buyer_nick is required"}
+
+ if not weixin_customer:
+ return {"msg": "当前客户未绑定微信账号"}
+
+ logger.info(f'用户新订单: {msg}')
+
+ order_id = msg.order_id
+
+ await msg_controller.new_order(order_id, weixin_customer, is_refund=msg.is_refund)
+ await msg_controller.sync_msg()
+ return {"msg": "success"}
+
+
+@router.get("/fill_customer_info", summary="填充客户信息")
+@unified_resp
+async def fill_customer_info():
+ await msg_controller.fill_customer_info()
+ return {"msg": "success"}
+
+@router.get("/list", summary="通过群ID获取到群信息以及同步数据到本地")
+@unified_resp
+async def list_msg(paginate: Paginate = Depends(), order: str = "-id", msg_filter: MsgFilter = Depends(), weixin_user: dict = Depends(get_weixin_user)):
+ print('paginate', paginate.page, paginate.page_size)
+ print('msg_filter', msg_filter)
+ print('weixin_user', weixin_user.username, weixin_user.userid)
+ # q = Q(owner_id='11')
+ q = Q(owner_id=weixin_user.userid)
+ if msg_filter.type is not None:
+ q &= Q(type=msg_filter.type)
+ if msg_filter.is_read is not None:
+ q &= Q(is_read=msg_filter.is_read)
+ if msg_filter.is_read:
+ order = '-read_at'
+
+ if msg_filter.is_refund is not None:
+ q &= Q(detail__contains={"is_refund": 1 if msg_filter.is_refund else 0})
+
+ total, msg_list = await msg_controller.list(paginate.page, paginate.page_size, order=[order], search=q)
+ return {"count": total, "lists": [ await msg.to_dict() for msg in msg_list]}
+
+@router.post("/set_read", summary="设置消息为已读")
+@unified_resp
+async def set_read(msg: MsgUpdate, weixin_user: dict = Depends(get_weixin_user)):
+ await msg_controller.set_read(msg.id)
+ return {"msg": "success"}
+
+@router.get("/get_order_user_days_before", summary="获取指定天数前的订单用户")
+@unified_resp
+async def get_order_user_days_before():
+ return await msg_controller.get_order_user_days_before(2)
+
diff --git a/app/api/v1/roles/__init__.py b/app/api/v1/roles/__init__.py
new file mode 100644
index 0000000..0b6957e
--- /dev/null
+++ b/app/api/v1/roles/__init__.py
@@ -0,0 +1,8 @@
+from fastapi import APIRouter
+
+from .roles import router
+
+roles_router = APIRouter()
+roles_router.include_router(router, tags=["角色模块"])
+
+__all__ = ["roles_router"]
diff --git a/app/api/v1/roles/roles.py b/app/api/v1/roles/roles.py
new file mode 100644
index 0000000..f43a304
--- /dev/null
+++ b/app/api/v1/roles/roles.py
@@ -0,0 +1,73 @@
+import logging
+
+from fastapi import APIRouter, Query
+from fastapi.exceptions import HTTPException
+from tortoise.expressions import Q
+
+from app.controllers import role_controller
+from app.schemas.base import Success, SuccessExtra
+from app.schemas.roles import *
+
+logger = logging.getLogger(__name__)
+router = APIRouter()
+
+
+@router.get("/list", summary="查看角色列表")
+async def list_role(
+ page: int = Query(1, description="页码"),
+ page_size: int = Query(10, description="每页数量"),
+ role_name: str = Query("", description="角色名称,用于查询"),
+):
+ q = Q()
+ if role_name:
+ q = Q(name__contains=role_name)
+ total, role_objs = await role_controller.list(page=page, page_size=page_size, search=q)
+ data = [await obj.to_dict() for obj in role_objs]
+ return SuccessExtra(data=data, total=total, page=page, page_size=page_size)
+
+
+@router.get("/get", summary="查看角色")
+async def get_role(
+ role_id: int = Query(..., description="角色ID"),
+):
+ role_obj = await role_controller.get(id=role_id)
+ return Success(data=await role_obj.to_dict())
+
+
+@router.post("/create", summary="创建角色")
+async def create_role(role_in: RoleCreate):
+ if await role_controller.is_exist(name=role_in.name):
+ raise HTTPException(
+ status_code=400,
+ detail="The role with this rolename already exists in the system.",
+ )
+ await role_controller.create(obj_in=role_in)
+ return Success(msg="Created Successfully")
+
+
+@router.post("/update", summary="更新角色")
+async def update_role(role_in: RoleUpdate):
+ await role_controller.update(id=role_in.id, obj_in=role_in)
+ return Success(msg="Updated Successfully")
+
+
+@router.delete("/delete", summary="删除角色")
+async def delete_role(
+ role_id: int = Query(..., description="角色ID"),
+):
+ await role_controller.remove(id=role_id)
+ return Success(msg="Deleted Success")
+
+
+@router.get("/authorized", summary="查看角色权限")
+async def get_role_authorized(id: int = Query(..., description="角色ID")):
+ role_obj = await role_controller.get(id=id)
+ data = await role_obj.to_dict(m2m=True)
+ return Success(data=data)
+
+
+@router.post("/authorized", summary="更新角色权限")
+async def update_role_authorized(role_in: RoleUpdateMenusApis):
+ role_obj = await role_controller.get(id=role_in.id)
+ await role_controller.update_roles(role=role_obj, menu_ids=role_in.menu_ids, api_infos=role_in.api_infos)
+ return Success(msg="Updated Successfully")
diff --git a/app/api/v1/users/__init__.py b/app/api/v1/users/__init__.py
new file mode 100644
index 0000000..c3b420e
--- /dev/null
+++ b/app/api/v1/users/__init__.py
@@ -0,0 +1,8 @@
+from fastapi import APIRouter
+
+from .users import router
+
+users_router = APIRouter()
+users_router.include_router(router, tags=["用户模块"])
+
+__all__ = ["users_router"]
diff --git a/app/api/v1/users/users.py b/app/api/v1/users/users.py
new file mode 100644
index 0000000..f1248c8
--- /dev/null
+++ b/app/api/v1/users/users.py
@@ -0,0 +1,87 @@
+import logging
+
+from fastapi import APIRouter, Body, Query
+from tortoise.expressions import Q
+
+from app.controllers.dept import dept_controller
+from app.controllers.user import user_controller
+from app.schemas.base import Fail, Success, SuccessExtra
+from app.schemas.users import *
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+
+@router.get("/list", summary="查看用户列表")
+async def list_user(
+ page: int = Query(1, description="页码"),
+ page_size: int = Query(10, description="每页数量"),
+ username: str = Query("", description="用户名称,用于搜索"),
+ email: str = Query("", description="邮箱地址"),
+ dept_id: int = Query(None, description="部门ID"),
+):
+ q = Q()
+ if username:
+ q &= Q(username__contains=username)
+ if email:
+ q &= Q(email__contains=email)
+ if dept_id is not None:
+ q &= Q(dept_id=dept_id)
+ total, user_objs = await user_controller.list(page=page, page_size=page_size, search=q)
+ data = [await obj.to_dict(m2m=True, exclude_fields=["password"]) for obj in user_objs]
+ for item in data:
+ dept_id = item.pop("dept_id", None)
+ item["dept"] = await (await dept_controller.get(id=dept_id)).to_dict() if dept_id else {}
+
+ return SuccessExtra(data=data, total=total, page=page, page_size=page_size)
+
+
+@router.get("/get", summary="查看用户")
+async def get_user(
+ user_id: int = Query(..., description="用户ID"),
+):
+ user_obj = await user_controller.get(id=user_id)
+ user_dict = await user_obj.to_dict(exclude_fields=["password"])
+ return Success(data=user_dict)
+
+
+@router.post("/create", summary="创建用户")
+async def create_user(
+ user_in: UserCreate,
+):
+ user = await user_controller.get_by_email(user_in.email)
+ if user:
+ return Fail(code=400, msg="The user with this email already exists in the system.")
+ new_user = await user_controller.create_user(obj_in=user_in)
+ await user_controller.update_roles(new_user, user_in.role_ids)
+ return Success(msg="Created Successfully")
+
+
+@router.post("/update", summary="用户管理中更新用户")
+async def update_user(
+ user_in: UserUpdate,
+):
+ user = await user_controller.update(id=user_in.id, obj_in=user_in)
+ await user_controller.update_roles(user, user_in.role_ids)
+ return Success(msg="Updated Successfully")
+
+@router.post("/update_user_online", summary="更新用户")
+async def update_user_online(
+ user_in: UserUpdateOnline,
+):
+ user = await user_controller.update(id=user_in.id, obj_in=user_in)
+ return Success(msg="Updated Successfully")
+
+@router.delete("/delete", summary="删除用户")
+async def delete_user(
+ user_id: int = Query(..., description="用户ID"),
+):
+ await user_controller.remove(id=user_id)
+ return Success(msg="Deleted Successfully")
+
+
+@router.post("/reset_password", summary="重置密码")
+async def reset_password(user_id: int = Body(..., description="用户ID", embed=True)):
+ await user_controller.reset_password(user_id)
+ return Success(msg="密码已重置为123456")
diff --git a/app/api/v1/weixin/__init__.py b/app/api/v1/weixin/__init__.py
new file mode 100644
index 0000000..c2804d4
--- /dev/null
+++ b/app/api/v1/weixin/__init__.py
@@ -0,0 +1,11 @@
+from fastapi import APIRouter
+from app.controllers.weixin import schedule as _schedule
+from .weixin import router
+from .weixin_user import router as weixin_user_router
+
+
+weixin_router = APIRouter()
+weixin_router.include_router(router, tags=["微信模块"])
+weixin_router.include_router(weixin_user_router, tags=["微信用户模块"])
+
+__all__ = ["weixin_router"]
diff --git a/app/api/v1/weixin/base.py b/app/api/v1/weixin/base.py
new file mode 100644
index 0000000..0c61ca7
--- /dev/null
+++ b/app/api/v1/weixin/base.py
@@ -0,0 +1,16 @@
+# app/core/dependency.py
+from typing import Annotated
+from fastapi import Header, HTTPException, Depends
+from app.core.ctx import set_ctx_weixin_user
+from app.models import WeixinUser
+
+async def get_weixin_user(token: str = Header(..., description="token验证")) -> WeixinUser:
+ user = await WeixinUser.filter(userid=token).first()
+ if not user:
+ raise HTTPException(status_code=401, detail="Weixin User Authentication failed")
+ # CTX_USER_ID.set(int(user.id))
+ set_ctx_weixin_user(int(user.id), f'{user.username}({user.english_name})')
+ return user
+
+DependWeixinUser = Depends(get_weixin_user)
+WeixinUserDep = Annotated[WeixinUser, DependWeixinUser]
diff --git a/app/api/v1/weixin/requirements.txt b/app/api/v1/weixin/requirements.txt
new file mode 100644
index 0000000..bfc3a20
--- /dev/null
+++ b/app/api/v1/weixin/requirements.txt
@@ -0,0 +1,2 @@
+httpx
+pypinyin
diff --git a/app/api/v1/weixin/weixin.py b/app/api/v1/weixin/weixin.py
new file mode 100644
index 0000000..8d28cf2
--- /dev/null
+++ b/app/api/v1/weixin/weixin.py
@@ -0,0 +1,95 @@
+# app/api/wechat.py
+from fastapi import APIRouter, Query
+# from app.controllers import wechat_sdk
+# from app.controllers.weixin_script import WeixinUser_controller
+from some_sdk.services.binder import xy_client
+from some_sdk.wk_weixin_sdk import auth
+from some_sdk.wk_weixin_sdk.apis import corp_group, extern_user
+from app.http_base import unified_resp
+
+
+import logging
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+# @router.get("/app-config")
+# @unified_resp
+# def get_config():
+# return wechat_sdk.get_base_config()
+
+# 获取企业身份签名
+@router.get("/jssdk-config", summary="获取企业微信 企业身份 JS-SDK 配置")
+@unified_resp
+async def jssdk_config(type: str = Query(..., description="类型"), url: str = Query(..., description="前端当前页面 URL(不含 #)")):
+ return await auth.get_jssdk_config(type, url)
+
+@router.get("/get_access_user_info", summary="获取访问用户身份")
+@unified_resp
+async def get_access_user_info(code: str = Query(..., description="code为企业成员点击了构造链接之后附加在url中参数")):
+ return await auth.get_access_user_info(code)
+
+@router.get("/get_external_group_chat_info", summary="获取外部群聊详细信息")
+@unified_resp
+async def get_external_group_chat_info(chat_id: str = Query(..., description="群对话ID")):
+ return await corp_group.get_external_group_chat_info(xy_client, chat_id)
+
+@router.get("/get_external_user_chat_info", summary="获取外部客户详细信息")
+@unified_resp
+async def get_external_user_chat_info(user_id: str = Query(..., description="为外部客户的userid")):
+ return await extern_user.get_external_user_chat_info(user_id)
+
+# @router.get("/get_external_user_list", summary="获取指定用户的所有外部客户")
+# @unified_resp
+# async def get_external_user_list(user_id: str = Query(..., description="企业成员的userid")):
+# return await wechat_sdk.get_external_user_list(user_id)
+
+# @router.get("/get_corp_user_id_list", summary="获取指公司所有员工")
+# @unified_resp
+# async def get_corp_user_id_list():
+# return await wechat_sdk.get_corp_user_id_list()
+
+# @router.get("/get_follow_user_list", summary="获取配置了客户联系功能的成员列表")
+# @unified_resp
+# async def get_follow_user_list():
+# return await wechat_sdk.get_follow_user_list()
+
+# @router.get("/get_follow_user_list", summary="获取配置了客户联系功能的成员列表")
+# @unified_resp
+# async def get_follow_user_list():
+# return await wechat_sdk.get_follow_user_list()
+
+# @router.get("/get_groupchat_list", summary="获取指定用户的客户群列表")
+# @unified_resp
+# async def get_groupchat_list():
+# return await wechat_sdk.get_groupchat_list(['XiYinShuo'])
+
+# @router.get("/update_all_user", summary="同步所有用户信息")
+# @unified_resp
+# async def update_all_user():
+# return await WeixinUser_controller.update_all_user()
+
+# @router.get("/update_all_user_by_group_chat", summary="通过群聊同步用户信息")
+# @unified_resp
+# async def update_all_user_by_group_chat():
+# return await WeixinUser_controller.update_all_user_by_group_chat()
+
+# @router.get("/convert_extenal_userid", summary="通过群聊同步用户信息")
+# @unified_resp
+# async def convert_extenal_userid():
+# return await wechat_sdk.convert_extenal_userid('wmKgOaDQAA37oxGfhBFCAFPqmICXNAZA')
+
+# @router.get("/update_all_user_by_xingyun", summary="通过外部系统同步用户信息")
+# @unified_resp
+# async def update_all_user_by_xingyun():
+# return await WeixinUser_controller.update_all_user_by_xingyun()
+
+# @router.get("/convert_extenal_userid", summary="转换三方应用的extenal_userid")
+# @unified_resp
+# async def convert_extenal_userid(user_id: str = Query(..., description="客户的userid")):
+# return await wechat_sdk.convert_extenal_userid(user_id)
+
+# @router.get("/get_external_contact_list", summary="获取已服务的外部联系人")
+# @unified_resp
+# async def get_external_contact_list(cursor: Optional[str] = Query(None, description="客户的userid")):
+# return await wechat_sdk.get_external_contact_list(cursor)
diff --git a/app/api/v1/weixin/weixin_user.py b/app/api/v1/weixin/weixin_user.py
new file mode 100644
index 0000000..5b9c5d4
--- /dev/null
+++ b/app/api/v1/weixin/weixin_user.py
@@ -0,0 +1,294 @@
+# app/api/wechat.py
+from fastapi import APIRouter, Query, BackgroundTasks, Depends
+from tortoise.expressions import Q
+from app.controllers.weixin.utils import get_buyer_nick_from_group_name
+from app.controllers.weixin.group import weixin_group_chat_controller
+from app.controllers.weixin.customer import weixin_customer_controller
+from app.controllers.weixin.user import weixin_user_controller
+from app.controllers.action import action_controller
+from app.schemas.weixin import WeixinUserBindInfo, WeixinOrderBindInfo, TriggerWeixinGroupChat, WeixinOrderRemark, WeixinCustomerFilter, WeixinCustomerCreate
+from app.http_base import unified_resp
+from app.schemas.apis import Paginate
+from .base import get_weixin_user
+
+from .base import WeixinUserDep
+from app.models.msg import ActionType
+
+from datetime import datetime, timedelta
+from app.utils.event_task import event_manager, EventType
+from app.core.cache import cache_if
+# from app.schemas.crm import CrmBindInfoCreate, CrmBindInfoUpdate
+
+import logging
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="")
+
+# 废弃
+@router.post("/list_user_join_group", summary="加载用户加入的群聊列表")
+@unified_resp
+async def list_user_join_group(customer: WeixinCustomerCreate, weixin_user: dict = Depends(get_weixin_user)):
+ print(f'list_user_join_group, customer: {customer}')
+ # user_join_group = await weixin_group_chat_controller.list_user_join_group(customer.xingyun_id)
+ user_join_group = await weixin_customer_controller.get_user_detail(customer.weixin_id)
+ return user_join_group
+
+@router.get("/list_customer", summary="加载客户列表")
+@unified_resp
+async def list_msg(paginate: Paginate = Depends(), order: str = "-id", customer_filter: WeixinCustomerFilter = Depends(), weixin_user: dict = Depends(get_weixin_user)):
+ print('paginate', paginate.page, paginate.page_size)
+ print('customer_filter', customer_filter)
+ print('weixin_user', weixin_user.username, weixin_user.userid)
+
+ q = Q()
+ data = {}
+ if customer_filter.order_id is not None:
+ q &= Q(order_id=customer_filter.order_id)
+ data['order_id'] = customer_filter.order_id
+ if customer_filter.shop_name is not None:
+ q &= Q(shop_name__contains=customer_filter.shop_name)
+ data['shop_name'] = customer_filter.shop_name
+ if customer_filter.union_id is not None:
+ # 是或者的关系 weixin_id\xingyun_id\taobao_id\union_id
+ q &= (Q(weixin_id=customer_filter.union_id) | Q(xingyun_id=customer_filter.union_id) | Q(taobao_id=customer_filter.union_id) | Q(union_id=customer_filter.union_id))
+ data['union_id'] = customer_filter.union_id
+ if customer_filter.union_name is not None:
+ # 是或者的关系 weixin_username\xingyun_name\taobao_name
+ q &= (Q(weixin_name__contains=customer_filter.union_name) | Q(xingyun_name__contains=customer_filter.union_name) | Q(taobao_name__contains=customer_filter.union_name))
+ data['union_name'] = customer_filter.union_name
+ if customer_filter.xingyun_tags is not None:
+ q &= Q(xingyun_tags__contains={"tagName": customer_filter.xingyun_tags})
+ data['xingyun_tags'] = customer_filter.xingyun_tags
+ if customer_filter.has_order is not None:
+ q &= Q(order_id__isnull=not customer_filter.has_order)
+ data['has_order'] = customer_filter.has_order
+
+ # 打印查询条件明文
+ print('raw query', data)
+
+ total, user_list = await weixin_customer_controller.list(paginate.page, paginate.page_size, order=[order], search=q)
+
+ lists = []
+ for user in user_list:
+ user_info = user.dump_dict()
+ lists.append(user_info)
+ user_info['user_join_group'] = await weixin_customer_controller.get_user_detail(user.weixin_id)
+
+ return {"count": total, "lists": lists}
+
+
+# 群聊入口
+@router.get("/get_user_info", summary="通过群ID获取到群信息以及同步数据到本地")
+@unified_resp
+async def get_external_group_chat_info(userid: str):
+ weixin_user = await weixin_user_controller.model.filter(userid=userid).first()
+ return weixin_user.to_dict()
+
+# 群聊入口
+@router.get("/group/get_external_group_chat_info", summary="通过群ID获取到群信息以及同步数据到本地")
+@unified_resp
+async def get_external_group_chat_info(chat_id: str, background_tasks: BackgroundTasks):
+ group_info = await weixin_group_chat_controller.get_external_group_chat_info(chat_id)
+
+ if group_info['detect_update']:
+ await event_manager.publish_async(EventType.GROUP_CHAT_UPDATED, group_info, background_tasks)
+
+ return group_info
+
+
+@router.get("/group/get_order_relative_user_list_by_weixin_groupid", summary="通过群ID获取订单相关用户列表")
+@unified_resp
+async def get_order_relative_user_list_by_weixin_groupid(chat_id: str = Query(..., description="群ID"), background_tasks: BackgroundTasks = None):
+ async with cache_if(f'order:chat:{chat_id}', ttl=60*3) as cache:
+ if cache.hit:
+ order_list = cache.value
+ if order_list:
+ order_list[0] = await weixin_group_chat_controller.get_user_detail_by_order(order_id=order_list[0]['ctid'])
+ else:
+ order_list = await _get_order_relative_user_list_by_weixin_groupid(chat_id, background_tasks)
+ cache.set(order_list)
+ return order_list
+
+async def _get_order_relative_user_list_by_weixin_groupid(chat_id: str = Query(..., description="群ID"), background_tasks: BackgroundTasks = None):
+ group_info = await weixin_group_chat_controller.get_external_group_chat_info(chat_id)
+ logger.info(f'group_info: {group_info}')
+
+ if group_info['detect_update'] or True:
+ await event_manager.publish_async(EventType.GROUP_CHAT_UPDATED, group_info, background_tasks)
+
+ name = group_info.get('name')
+ logger.debug(f'群聊名称: {name} chat_id: {chat_id}')
+
+ user_order_list = []
+
+ external_member_list = group_info['external_member_list']
+ if external_member_list:
+ userid_list = [member['userid'] for member in external_member_list if member.get('userid')]
+ logger.debug(f'userid_list: {userid_list}')
+ customer_list = await weixin_customer_controller.model.filter(weixin_id__in=userid_list, taobao_id__isnull=False).all()
+ for customer in customer_list:
+ buyer = customer.taobao_name or customer.taobao_id
+ if not buyer: continue
+
+ logger.debug(f'buyer: {buyer}')
+ result, order_list = await weixin_group_chat_controller.get_order_relative_user_list_by_weixin_userid(customer.weixin_id)
+ if not result: continue
+ user_order_list = order_list
+ break
+ # return await weixin_group_chat_controller.get_order_relative_user_list_by_weixin_group_name(buyer_nick)
+ # return await weixin_group_chat_controller.get_order_relative_user_list_by_weixin_group_buyer_id(buyer_id)
+
+ if not user_order_list:
+ logger.warning(f'群内成员没有订单,将根据群聊名称 {name} 来获取订单相关用户列表')
+ assert name, "当前群聊未命名"
+ buyer_nick = get_buyer_nick_from_group_name(name)
+ logger.debug(f'name: {name}; buyer_nick: {buyer_nick}')
+ assert buyer_nick, "当前群聊未命名"
+ user_order_list = await weixin_group_chat_controller.get_order_relative_user_list_by_weixin_group_name(buyer_nick)
+
+ if group_info['detect_update']:
+ try:
+ await action_controller.check_create_group_action_is_done(group_info, user_order_list[0]['shop_name'] if user_order_list else None)
+ except Exception as e:
+ logger.exception(f'检查创建群聊动作是否完成失败: {e}')
+
+ return user_order_list
+
+
+
+# @router.get("/group/load_xingyun_group_info", summary="通过群ID获取订单相关用户列表")
+# @unified_resp
+# async def load_xingyun_group_info():
+# return await weixin_group_chat_controller.load_xingyun_group_info()
+
+
+@router.get("/get_user_detail_by_order", summary="通过订单ID获取订单相关用户详情")
+@unified_resp
+async def get_user_detail_by_order(order_id: str):
+ return await weixin_group_chat_controller.get_user_detail_by_order(order_id)
+
+
+@router.get("/get_erp_order_log", summary="通过订单ID获取订单日志")
+@unified_resp
+async def get_erp_order_log(order_id: str, login_user: WeixinUserDep):
+ result = await weixin_group_chat_controller.get_erp_order_log(order_id)
+ try:
+ await action_controller.new_action(ActionType.VIEW_ERP_LOG, login_user, order_id=order_id)
+ except Exception as e:
+ logger.exception(e)
+ logger.error(f'员工操作日志记录失败')
+ return result
+
+
+# 通过私聊入口
+@router.get("/get_order_relative_user_list_by_weixin_userid", summary="通过用户ID获取订单相关用户列表")
+@unified_resp
+async def get_order_relative_user_list_by_weixin_userid(userid: str, background_tasks: BackgroundTasks):
+ result, order_list = await weixin_group_chat_controller.get_order_relative_user_list_by_weixin_userid(userid)
+ if not result:
+ await event_manager.publish_async(EventType.OPEN_PERSONAL_CHAT, {"userid": userid}, background_tasks)
+
+ return order_list
+
+
+@router.post("/bind_order", summary="绑定订单")
+@unified_resp
+async def bind_order(bind_in: WeixinOrderBindInfo, background_tasks: BackgroundTasks, login_user: WeixinUserDep):
+ bind_in.userid = bind_in.userid.strip()
+ bind_in.order_id = bind_in.order_id.strip()
+ logger.info(f'【绑定订单】userid: {bind_in.userid}; order_id: {bind_in.order_id}')
+
+ old_info = await weixin_customer_controller.model.filter(weixin_id=bind_in.userid, order_id=bind_in.order_id).first()
+ assert not old_info, "不用为客户绑定相同订单"
+
+ result, orders = await weixin_customer_controller.bind_order(bind_in)
+ try:
+ await action_controller.new_action(ActionType.BIND_ORDER, login_user, **bind_in.model_dump(exclude_unset=True))
+ if orders and bind_in.remark:
+ orders = [{
+ "title": order.get("title"),
+ "ctid": order.get("ctid"),
+ "trade_no": order.get("trade_no"),
+ "remark": order.get("remark", ""),
+ } for order in orders]
+
+ need_monitor_order_list = await weixin_customer_controller.remark_order_by_order_id(remark=bind_in.remark, orders=orders)
+ await action_controller.new_action(ActionType.WRITE_REMARK, login_user, order_id=bind_in.order_id, remark=bind_in.remark, orders=need_monitor_order_list, done=len(need_monitor_order_list) == 0)
+ except Exception as e:
+ logger.exception(e)
+ logger.error(f'员工操作日志记录失败')
+
+ # 用户触发绑定时,及时同步到星云有客中
+ await event_manager.publish_async(EventType.BIND_ORDER_FOR_USER, orders[0] if orders else {}, background_tasks)
+ return result
+
+
+@router.post("/remark_order", summary="为订单添加额外备注")
+@unified_resp
+async def remark_order(remark_in: WeixinOrderRemark, login_user: WeixinUserDep):
+ if not remark_in.remark:
+ logger.warning(f'订单备注为空,将不执行任何操作')
+ return False
+
+ try:
+ need_monitor_order_list = await weixin_customer_controller.remark_order_by_order_id(remark=remark_in.remark, order_id=remark_in.order_id)
+ await action_controller.new_action(ActionType.WRITE_REMARK, login_user, order_id=remark_in.order_id, remark=remark_in.remark, orders=need_monitor_order_list, done=len(need_monitor_order_list) == 0)
+ except Exception as e:
+ logger.exception(e)
+ logger.error(f'员工操作日志记录失败')
+ return bool(need_monitor_order_list)
+
+
+@router.post("/bind_user", summary="用户绑定客户")
+@unified_resp
+async def bind_user(bind_info: WeixinUserBindInfo, login_user: WeixinUserDep):
+ result = await weixin_customer_controller.bind_user(bind_info=bind_info)
+ try:
+ await action_controller.new_action(ActionType.BIND_USER, login_user, **bind_info.model_dump(exclude_unset=True))
+ except Exception as e:
+ logger.exception(e)
+ logger.error(f'员工操作日志记录失败')
+ return result
+
+
+@router.post("/bind_inner_user", summary="用户绑定内部公司成员(erp和企微架构不同)")
+@unified_resp
+async def bind_inner_user(bind_info: WeixinUserBindInfo, login_user: WeixinUserDep):
+ result = await weixin_user_controller.bind_user(bind_info)
+ try:
+ await action_controller.new_action(ActionType.BIND_USER, login_user, **bind_info.model_dump(exclude_unset=True))
+ except Exception as e:
+ logger.exception(e)
+ logger.error(f'员工操作日志记录失败')
+ return result
+
+@router.get("/load_recent_user_from_xingyun", summary="加载最近N天的客户数据")
+@unified_resp
+async def load_user_from_xingyun(days_range: int = Query(3, description="加载最近N天的客户数据")):
+
+ now_time = datetime.now()
+ start_time = (now_time - timedelta(days=days_range)).replace(hour=0, minute=0, second=0, microsecond=0)
+ end_time = now_time + timedelta(days=1)
+ return await weixin_customer_controller.load_user_from_xingyun(add_time_start=start_time, add_time_end=end_time)
+
+@router.get("/load_all_user_from_xingyun", summary="加载所有客户数据")
+@unified_resp
+async def load_all_user_from_xingyun(background_tasks: BackgroundTasks):
+ await event_manager.publish_async(EventType.SYNC_XINGYUN_CONTACT_INFO, {}, background_tasks)
+ return True
+
+@router.post("/trigger_create_group_chat", summary="触发创建群聊")
+@unified_resp
+async def trigger_create_group_chat(group_chat: TriggerWeixinGroupChat, login_user: WeixinUserDep):
+ try:
+ await action_controller.new_action(ActionType.CREATE_GROUP, login_user, **group_chat.model_dump(exclude_unset=True))
+ if group_chat.order_id:
+ username = login_user.english_name or login_user.nickname
+ username = username.split('-印刷')[0]
+ remark = f'企微联系{username}拉群'
+ need_monitor_order_list = await weixin_customer_controller.remark_order_by_order_id(remark=remark, order_id=group_chat.order_id)
+ await action_controller.new_action(ActionType.WRITE_REMARK, login_user, order_id=group_chat.order_id, remark=remark, orders=need_monitor_order_list, done=len(need_monitor_order_list) == 0)
+ except Exception as e:
+ logger.exception(e)
+ logger.error(f'员工操作日志记录失败')
+ return True
diff --git a/app/controllers/__init__.py b/app/controllers/__init__.py
new file mode 100644
index 0000000..f12bef9
--- /dev/null
+++ b/app/controllers/__init__.py
@@ -0,0 +1,2 @@
+from .role import role_controller as role_controller
+from .user import user_controller as user_controller
diff --git a/app/controllers/action.py b/app/controllers/action.py
new file mode 100644
index 0000000..8e3636c
--- /dev/null
+++ b/app/controllers/action.py
@@ -0,0 +1,109 @@
+from app.core.crud import CRUDBase
+from app.schemas.msg import (
+ ActionCreate,
+ ActionUpdate,
+)
+from app.models.automation import Action, ActionType
+from app.models.weixin import WeixinCustomer, CustomerGroup, WeixinGroupChat, WeixinUser
+from some_sdk.lintao_sdk.biz.by_order import get_order_relative_user
+from some_sdk.services.binder import lintao_client
+from app.controllers.weixin.customer import weixin_customer_controller
+from datetime import datetime
+
+import logging
+logger = logging.getLogger(__name__)
+
+class ActionController(CRUDBase[Action, ActionCreate, ActionUpdate]):
+ def __init__(self):
+ super().__init__(model=Action)
+
+ async def new_action(self, type: ActionType, user, done=False, **kwargs):
+ action = ActionCreate(userid=user.userid, username=f'{user.username}({user.english_name})', done=done, type=type, detail=kwargs)
+ return await self.create(action)
+
+ async def check_create_group_action_is_done(self, group_info, shop_name: str = None):
+ name = group_info.get('name')
+ chat_id = group_info.get('chat_id')
+
+ # 完成建群操作
+ create_group_action = await self.model.filter(type=ActionType.CREATE_GROUP, done=False, detail__contains={"group_name": name}).order_by('-id').first()
+ if not create_group_action: return
+
+ create_group_action_detail = create_group_action.detail
+ create_group_action_detail.update({
+ "chat_id": chat_id,
+ })
+
+ create_group_action.done = True
+ create_group_action.done_at = datetime.now()
+ create_group_action.result = group_info
+ create_group_action.detail = create_group_action_detail
+ await create_group_action.save()
+
+ # 完成建群操作后,将群成员添加到客户群中
+ weixin_group = await WeixinGroupChat.filter(chat_id=chat_id).first()
+ if not weixin_group: return
+
+ staff = await WeixinUser.filter(userid=create_group_action.userid).first()
+
+ external_user_ids = create_group_action.detail.get('external_user_ids') or []
+ for userid in external_user_ids:
+ customer = await WeixinCustomer.filter(weixin_id=userid).first()
+ if not customer: continue
+
+ membership = await CustomerGroup.create(
+ staff_userid=create_group_action.userid,
+ customer_userid=customer.weixin_id,
+ group_chatid=chat_id,
+ staff=staff,
+ customer=customer,
+ group=weixin_group,
+ shop_name=shop_name,
+ order_id=create_group_action.detail.get('order_id'),
+ join_time=create_group_action.created_at,
+ )
+ logger.info(f'【建群成功】保存客户的群聊 {name}({chat_id}) 信息')
+
+ async def check_remark_action_is_done(self):
+ actions = await self.model.filter(done=False, type=ActionType.WRITE_REMARK)
+ logger.info(f'检查备注操作是否完成,监控列表中:共有{len(actions)}条')
+
+ update_list = []
+ for action in actions:
+ action_info = action.detail
+ order_id = action_info.get("order_id")
+ remark = action_info.get("remark")
+ if not order_id: continue
+
+ done = False
+ action_result = []
+
+ if remark:
+ async for order in get_order_relative_user(lintao_client, trade_no=order_id):
+
+ try:
+ await weixin_customer_controller.remark_order(order, remark=remark)
+ if order.get('title'):
+ logger.info(f'【监控列表中,订单被领单】重新为订单 {order.get("trade_no")} 打上备注 {remark}')
+ # 未领单的订单,需要添加到监控列表中进行监控,防止被刷掉
+ done = True
+ action_result.append({
+ "trade_no": order.get("trade_no"),
+ "title": order.get("title"),
+ "remark": remark,
+ })
+ except Exception as e:
+ logger.exception(f'【监控列表中,订单被领单】重新为订单为订单 {order.get("trade_no")} 打上备注 {remark} 失败,异常: {e}')
+
+ if done or not remark:
+ action.done = True
+ action.done_at = datetime.now()
+ action.result = action_result
+ update_list.append(action)
+
+ if update_list:
+ await self.model.bulk_update(update_list, fields=['done', 'result', 'done_at'])
+
+
+action_controller = ActionController()
+
diff --git a/app/controllers/api.py b/app/controllers/api.py
new file mode 100644
index 0000000..33e5682
--- /dev/null
+++ b/app/controllers/api.py
@@ -0,0 +1,46 @@
+from fastapi.routing import APIRoute
+
+from app.core.crud import CRUDBase
+import logging
+from app.models.admin import Api
+from app.schemas.apis import ApiCreate, ApiUpdate
+
+logger = logging.getLogger(__name__)
+
+class ApiController(CRUDBase[Api, ApiCreate, ApiUpdate]):
+ def __init__(self):
+ super().__init__(model=Api)
+
+ async def refresh_api(self):
+ from app import app
+
+ # 删除废弃API数据
+ all_api_list = []
+ for route in app.routes:
+ # 只更新有鉴权的API
+ if isinstance(route, APIRoute) and len(route.dependencies) > 0:
+ all_api_list.append((list(route.methods)[0], route.path_format))
+ delete_api = []
+ for api in await Api.all():
+ if (api.method, api.path) not in all_api_list:
+ delete_api.append((api.method, api.path))
+ for item in delete_api:
+ method, path = item
+ logger.debug(f"API Deleted {method} {path}")
+ await Api.filter(method=method, path=path).delete()
+
+ for route in app.routes:
+ if isinstance(route, APIRoute) and len(route.dependencies) > 0:
+ method = list(route.methods)[0]
+ path = route.path_format
+ summary = route.summary
+ tags = list(route.tags)[0]
+ api_obj = await Api.filter(method=method, path=path).first()
+ if api_obj:
+ await api_obj.update_from_dict(dict(method=method, path=path, summary=summary, tags=tags)).save()
+ else:
+ logger.debug(f"API Created {method} {path}")
+ await Api.create(**dict(method=method, path=path, summary=summary, tags=tags))
+
+
+api_controller = ApiController()
diff --git a/app/controllers/automation/action.py b/app/controllers/automation/action.py
new file mode 100644
index 0000000..c1263f4
--- /dev/null
+++ b/app/controllers/automation/action.py
@@ -0,0 +1,82 @@
+# app/controllers/automation.py (继续追加)
+from app.core.crud import CRUDBase
+from typing import List, Optional, Dict, Any
+from app.models.automation import Action
+from app.models.enums import ActionType
+from app.schemas.automation import ActionCreate, ActionUpdate
+from app.utils.common import transform_pydantic_to_list
+from app.schemas.automation import NotifyAction
+
+class ActionController(CRUDBase[Action, ActionCreate, ActionUpdate]):
+ def __init__(self):
+ super().__init__(model=Action)
+
+ async def get_pending_actions_by_task(self, task_id: int) -> List[Action]:
+ """获取某任务中未完成的动作"""
+ return await self.model.filter(task_id=task_id, done=False).all()
+
+ async def list_automation_actions(self) -> List[Dict[str, Any]]:
+ """获取动作类型映射"""
+
+ ActionTypeMap = [
+ {"value": ActionType.CREATE_GROUP, "label": "创建群聊", "automation": True, "allowAutoExecute": False, "autoExecute": False, "enable": True},
+ {"value": ActionType.VIEW_ERP_LOG, "label": "查看ERP日志", "automation": False, "allowAutoExecute": False, "autoExecute": False, "enable": True},
+ {"value": ActionType.BIND_USER, "label": "绑定用户", "automation": False, "allowAutoExecute": False, "autoExecute": False, "enable": True},
+ {"value": ActionType.BIND_ORDER, "label": "绑定订单", "automation": False, "allowAutoExecute": False, "autoExecute": False, "enable": True},
+ {"value": ActionType.WRITE_REMARK, "label": "写ERP额外备注", "automation": True, "allowAutoExecute": True, "autoExecute": True, "enable": True},
+ {"value": ActionType.SEND_WECHAT_NOTIFY, "label": "发送通知", "automation": True, "allowAutoExecute": True, "autoExecute": True, "enable": True, "schema": transform_pydantic_to_list(NotifyAction)},
+ {"value": ActionType.CLEAN_GROUP, "label": "清理群聊", "automation": True, "allowAutoExecute": False, "autoExecute": True, "enable": True},
+ {"value": ActionType.SET_GROUP_ADMIN, "label": "设置群管理员", "automation": True, "allowAutoExecute": False, "autoExecute": True, "enable": True},
+ ]
+
+ return [item for item in ActionTypeMap if item["automation"]]
+
+ async def mark_action_done(
+ self,
+ action_id: int,
+ userid: str,
+ username: str,
+ result: Dict[str, Any],
+ notes: Optional[str] = None
+ ) -> Action:
+ """标记动作为已完成"""
+ update_data = ActionUpdate(
+ done=True,
+ done_at=self.model._meta.db_fields.get("done_at").to_db_value(None, None), # 实际用 datetime.now()
+ userid=userid,
+ username=username,
+ result=result,
+ notes=notes
+ )
+ # Note: 建议在 service 层处理 done_at = datetime.utcnow()
+ return await self.update(action_id, update_data)
+
+ async def create_actions_for_task(self, task_id: int, actions_def: List[Dict]) -> List[Action]:
+ """为任务批量创建动作实例"""
+ actions = []
+ for act in actions_def:
+ action_in = ActionCreate(
+ task_id=task_id,
+ type=act.get("type"),
+ detail=act.get("detail", {}),
+ done=False
+ )
+ action = await self.create(action_in)
+ actions.append(action)
+ return actions
+
+ async def trigger_scenarios(self, scenario_list: List[Action], event_data: Dict[str, Any]) -> None:
+ """触发场景执行"""
+ for scenario in scenario_list:
+ if not scenario.enabled:
+ continue
+
+ for action in scenario.actions:
+ if not action.enabled:
+ continue
+
+ if action.type == ActionType.SEND_WECHAT_NOTIFY:
+ await self.trigger_scenarios_by_action(action, event_data)
+
+
+action_controller = ActionController()
\ No newline at end of file
diff --git a/app/controllers/automation/scenario.py b/app/controllers/automation/scenario.py
new file mode 100644
index 0000000..4911d23
--- /dev/null
+++ b/app/controllers/automation/scenario.py
@@ -0,0 +1,222 @@
+# app/controllers/automation.py
+
+from typing import List, Optional, Tuple, Dict, Any
+from app.core.crud import CRUDBase
+from app.models.automation import Scenario, ScenarioTriggerIndex, ScenarioScope
+from app.schemas.automation import ScenarioCreate, ScenarioUpdate
+from app.utils.event_task import event_manager
+
+import logging
+logger = logging.getLogger(__name__)
+
+class ScenarioController(CRUDBase[Scenario, ScenarioCreate, ScenarioUpdate]):
+ def __init__(self):
+ super().__init__(model=Scenario)
+
+ async def update_trigger_index(self, scenario: Scenario, user_id: str) -> Scenario:
+ """更新场景"""
+ add_list = []
+ event_set = set()
+ for condition in scenario.trigger.get('conditions') or []:
+ event_name = condition.get('event_name', '')
+ if event_name and event_name in event_set:
+ continue
+ event_set.add(event_name)
+
+ orm = ScenarioTriggerIndex(
+ owner_user_id=user_id,
+ scenario_id=scenario.id,
+ is_global=scenario.is_global,
+ event_name=condition.get('event_name', ''),
+ enabled=condition.get('enabled', True),
+ scope=condition.get('scope', ScenarioScope.PERSONAL),
+ )
+ add_list.append(orm)
+
+ if add_list:
+ await ScenarioTriggerIndex.bulk_create(add_list)
+ return True
+
+ return False
+
+ async def new_scenario(self, scenario_in: ScenarioCreate, user_id: str) -> Scenario:
+ """创建新场景"""
+ obj = await self.create(scenario_in)
+
+ # 更新触发条件
+ if obj.trigger:
+ await self.update_trigger_index(obj, user_id)
+
+ return obj
+
+ async def update_scenario(self, scenario_in: ScenarioUpdate, user_id: str) -> Scenario:
+ """更新场景"""
+ obj = await self.update(id=scenario_in.id, obj_in=scenario_in)
+
+ # 更新触发条件
+ if obj.trigger:
+ await ScenarioTriggerIndex.filter(scenario_id=obj.id).delete()
+ await self.update_trigger_index(obj, user_id)
+
+ return obj
+
+ async def remove_scenario(self, scenario_id: int) -> None:
+ """删除场景"""
+ await self.model.filter(id=scenario_id).delete()
+ await ScenarioTriggerIndex.filter(scenario_id=scenario_id).delete()
+ return True
+
+ async def list_automation_events(self) -> List[Dict[str, Any]]:
+ """获取所有自动化事件"""
+ return [a for a in event_manager.automation_event_handlers if a.get("automation_event")]
+
+ async def get_global_scenarios(self, enabled: bool = True) -> List[Scenario]:
+ """获取所有启用的全局场景"""
+ return await self.model.filter(is_global=True, enabled=enabled).all()
+
+ async def get_user_scenarios(self, user_id: str, enabled: bool = True) -> List[Scenario]:
+ """获取某用户的启用个人场景"""
+ return await self.model.filter(owner_user_id=user_id, is_global=False, enabled=enabled).all()
+
+ async def get_applicable_scenarios(self, user_id: str, enabled: bool = True) -> List[Scenario]:
+ """获取对某用户生效的所有场景(全局 + 个人)"""
+ global_scenarios = await self.get_global_scenarios(enabled=enabled)
+ personal_scenarios = await self.get_user_scenarios(user_id, enabled=enabled)
+ return global_scenarios + personal_scenarios
+
+
+class AutomationScenarioController(ScenarioController):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ async def find_active_scenarios(self, event_name: str, event_data: Dict[str, Any]) -> List[Scenario]:
+ """查找所有实际激活的场景配置"""
+ scenario_index_list = await ScenarioTriggerIndex.filter(enabled=True, event_name=event_name).all()
+
+ scenario_list = []
+ if scenario_index_list:
+ scenario_list = await self.model.filter(id__in=[s.scenario_id for s in scenario_index_list]).all()
+
+ logger.info(f'find_active_scenarios: {len(scenario_list)}')
+
+ active_scenarios = []
+ for scenario in scenario_list:
+ result, reason = await self.check_is_applicable(scenario, event_data)
+ if result:
+ active_scenarios.append((scenario, reason))
+
+ return active_scenarios
+
+ async def check_is_applicable(self, scenario: Scenario, event_data: Dict[str, Any]) -> tuple[bool, str]:
+ """检查场景是否适用于当前事件"""
+
+ trigger = scenario.trigger
+ # 如果没有条件,默认返回True,即只要事件发生就会激活场景
+ if not trigger or not trigger.get('conditions'):
+ logger.info(f'scenario {scenario.id} trigger without conditions, always return True')
+ return True, '没有设置具体的触发条件,事件发生就激活场景'
+
+ logic = trigger.get('logic', 'and')
+ conditions = trigger.get('conditions', [])
+
+ if logic == 'or':
+ for condition in conditions:
+ condition = condition.get('condition', {})
+
+ result, reason = self.check_condition(condition, event_data)
+ if result:
+ logger.info(f'scenario {scenario.id} trigger condition {condition} return True, reason: {reason}')
+ return True, reason
+ return False, '所有触发条件都不满足'
+ elif logic == 'and':
+
+ reason_list = []
+ for condition in conditions:
+ condition = condition.get('condition', {})
+
+ result, reason = self.check_condition(condition, event_data)
+ if not result:
+ logger.info(f'scenario {scenario.id} trigger condition {condition} return False, reason: {reason}')
+ return False, f'触发条件 {condition} 不满足, 原因: {reason}'
+ else:
+ reason_list.append(reason)
+
+ return True, f'{", ".join(reason_list)}'
+
+ raise ValueError(f"Invalid logic operator: {logic}")
+
+ def check_condition(self, condition: Dict[str, Any], event_data: Dict[str, Any]) -> tuple[bool, str]:
+ """检查单个条件是否满足"""
+ def find_field_val(field: str, event_data: Dict[str, Any]) -> Any:
+ """递归查找字段值"""
+ if '.' in field:
+ parts = field.split('.')
+ current = event_data
+ for part in parts:
+ if isinstance(current, list):
+ # current = current[int(part)]
+ result = []
+ for item in current:
+ # find_field_val
+ field_val = find_field_val(part, item)
+ if field_val is not None:
+ result.append(field_val)
+ return result
+ elif isinstance(current, dict):
+ current = current.get(part, None)
+ else:
+ return None
+ return current
+ return event_data.get(field, None)
+
+ if not condition or not condition.get('field', ''):
+ logger.error(f'scenario check_condition: field is empty, 当做无效条件处理')
+ return True, '没有设置具体的触发条件,事件发生就激活场景'
+
+ field = condition.get('field', '')
+
+ value = condition.get('value', '')
+ operator = condition.get('operator', '')
+ field_val = find_field_val(field, event_data)
+ if field_val is None:
+ logger.error(f'scenario check_condition: field {field} value is None, event_data: {event_data}')
+ return False, f'触发条件 {condition} 字段 {field} 不存在'
+
+ # 字符串比较
+ if operator in ['contains', 'not_contains']:
+ # if not isinstance(field_val, str):
+ # return False, f'字段 {field} 不是字符串类型'
+ if operator == 'contains' and value in field_val:
+ return True, f'字段 {field} 包含 {value}'
+ if operator == 'not_contains' and value not in field_val:
+ return True, f'字段 {field} 不包含 {value}'
+ return False, f'字段 {field} 不满足 {operator} {value}'
+
+ # 数字、时间比较
+ if operator in ['gt', 'gte', 'lt', 'lte', 'eq', 'ne']:
+ if not isinstance(field_val, (int, float, str)):
+ return False, f'字段 {field} 不是数字或时间类型'
+ try:
+ field_val = float(field_val)
+ value = float(value)
+ except ValueError:
+ return False, f'字段 {field} 不是数字或时间类型'
+
+ if operator == 'gt' and field_val > value:
+ return True, f'字段 {field} 大于 {value}'
+ if operator == 'gte' and field_val >= value:
+ return True, f'字段 {field} 大于等于 {value}'
+ if operator == 'lt' and field_val < value:
+ return True, f'字段 {field} 小于 {value}'
+ if operator == 'lte' and field_val <= value:
+ return True, f'字段 {field} 小于等于 {value}'
+ if operator == 'eq' and field_val == value:
+ return True, f'字段 {field} 等于 {value}'
+ if operator == 'ne' and field_val != value:
+ return True, f'字段 {field} 不等于 {value}'
+
+ return False, f'字段 {field} 不满足 {operator} {value}'
+
+
+
+automation_scenario_controller = AutomationScenarioController()
\ No newline at end of file
diff --git a/app/controllers/automation/task.py b/app/controllers/automation/task.py
new file mode 100644
index 0000000..74992d0
--- /dev/null
+++ b/app/controllers/automation/task.py
@@ -0,0 +1,77 @@
+# app/controllers/automation.py (继续追加)
+from app.core.crud import CRUDBase
+from typing import List, Optional, Dict, Any, Tuple
+
+from tortoise.expressions import Q
+from app.models.automation import Task, Scenario, ScenarioScope
+from app.schemas.automation import TaskCreate, TaskUpdate
+from datetime import datetime, timedelta
+
+class TaskController(CRUDBase[Task, TaskCreate, TaskUpdate]):
+ def __init__(self):
+ super().__init__(model=Task)
+
+ async def get_user_tasks(
+ self,
+ user_id: str,
+ status: Optional[str] = None,
+ page: int = 1,
+ page_size: int = 20
+ ) -> Tuple[int, List[Task]]:
+ """分页获取某用户的待办(支持按状态过滤)"""
+ query = Q(assignee_user_id=user_id)
+ if status:
+ query &= Q(status=status)
+ return await self.list(page=page, page_size=page_size, search=query, order=["-created_at"])
+
+ async def get_tasks_by_customer(self, customer_id: str) -> List[Task]:
+ """获取与某客户相关的所有待办(用于侧边栏上下文)"""
+ return await self.model.filter(related_customer_id=customer_id).order_by("-created_at").all()
+
+ async def create_from_scenario(
+ self,
+ scenario: Scenario,
+ event_data: Optional[Dict[str, Any]] = None,
+ assignee_user_id: Optional[str] = None,
+ assignee_username: Optional[str] = None,
+ reason: Optional[str] = None,
+ ) -> Task:
+ """根据场景模板创建待办任务"""
+ # 计算截止时间(示例:场景中定义的 due_days 天后)
+ due_at = scenario.due_days and (datetime.now() + timedelta(days=scenario.due_days)) or None
+
+ owner_user_id = None
+ if scenario.scope == ScenarioScope.ALL:
+ owner_user_id = ScenarioScope.ALL
+ elif scenario.scope == ScenarioScope.PERSONAL:
+ owner_user_id = scenario.owner_user_id
+
+ task_in = TaskCreate(
+ title=scenario.title,
+ reason=reason,
+ notes=scenario.notes,
+ event_data=event_data or {},
+ source_scenario=scenario,
+ source_scenario_id=scenario.id,
+ owner_user_id=owner_user_id,
+ assignee_user_id=assignee_user_id,
+ assignee_username=assignee_username,
+ due_at=due_at,
+ status="pending",
+ auto_closeable=True,
+ )
+ return await self.create(task_in)
+
+ async def auto_close_by_event(self, event_type: str, payload: Dict[str, Any]) -> List[Task]:
+ """
+ 根据事件自动关闭匹配的待办(用于自动消除)
+ 例如:当 group_id 匹配且动作含 create_group 时,关闭待办
+ """
+ # 示例逻辑:后续可扩展为规则引擎
+ closed_tasks = []
+ # 这里可遍历 pending 任务,检查 content 中的动作是否已被事件满足
+ # 为简化,此处留作扩展点
+ return closed_tasks
+
+
+task_controller = TaskController()
\ No newline at end of file
diff --git a/app/controllers/crm.py b/app/controllers/crm.py
new file mode 100644
index 0000000..334b5cf
--- /dev/null
+++ b/app/controllers/crm.py
@@ -0,0 +1,235 @@
+import asyncio
+from typing import List, Dict, Any
+
+import concurrent # 添加这行
+import concurrent.futures
+
+from app.core.crud import CRUDBase
+from app.schemas.crm import (
+ CrmCustomerCreate,
+ CrmCustomerUpdate,
+ CrmCustomerCreate,
+ CrmBindInfoCreate,
+ CrmBindInfoUpdate,
+)
+
+from some_sdk.services import binder as binder_service
+from some_sdk.lintao_sdk.biz.by_order import get_order_relative_user
+from app.models.weixin import CrmCustomer, CrmBindInfo
+from tortoise.expressions import Subquery
+from tortoise.functions import Count # 导入 Count 函数
+
+import logging
+logger = logging.getLogger(__name__)
+
+def split_generator(iterable, chunk_size=50):
+ """
+ 将可迭代对象拆分为多个子列表
+ """
+ chunk = []
+ for item in iterable:
+ chunk.append(item)
+ if len(chunk) == chunk_size:
+ yield chunk
+ chunk = []
+ if chunk:
+ yield chunk
+
+class CrmCustomerController(CRUDBase[CrmCustomer, CrmCustomerCreate, CrmCustomerUpdate]):
+ def __init__(self):
+ super().__init__(model=CrmCustomer)
+
+ async def get_unbound_customers_method_four_alt(self, limit: int = 50):
+ # 创建子查询
+ bound_ids_subquery = CrmBindInfo.all().values_list('customer_id', flat=True)
+
+ # 使用正确的子查询语法
+ unbound_customers = await self.model.filter(
+ cid__not_in=Subquery(bound_ids_subquery)
+ ).limit(limit)
+ return unbound_customers
+
+ async def bind_crm_all_customer_by_db(self):
+
+ # 定义一个函数来执行单个同步的 bind_shop_info 调用
+ def sync_fetch_bind_info(user_data: Dict[str, Any]):
+ # 同步调用 binder_service
+ all_users = []
+ for crm_user, user_list_from_bind in binder_service.bind_shop_info([user_data]):
+ all_users.extend(user_list_from_bind)
+ return all_users
+
+ parsed_set = set()
+ while True:
+ # 获取一批未绑定的用户 ORM 对象
+ unbound_orm_objects = await self.get_unbound_customers_method_four_alt(limit=50)
+ if not unbound_orm_objects:
+ break
+ print(f'获取到 {len(unbound_orm_objects)} 个待绑定的 ORM 对象', flush=True)
+ expected_cids = {obj.cid for obj in unbound_orm_objects if obj.cid is not None and obj.cid not in parsed_set}
+ if not expected_cids: break
+ user_data_list = [await obj.to_dict() for obj in unbound_orm_objects if obj.cid not in parsed_set]
+ [parsed_set.add(cid) for cid in expected_cids]
+ print(f'待绑定用户数(转换后):{len(user_data_list)}', flush=True)
+ await self._bind_crm_all_customer_by_db(user_data_list, expected_cids, sync_fetch_bind_info)
+
+ async def _bind_crm_all_customer_by_db(self, not_in_db_list: List[Dict[str, Any]], expected_cids: set, sync_fetch_bind_info):
+ print(f'待绑定用户数(传入列表):{len(not_in_db_list)}', flush=True)
+
+ # --- 并发执行所有同步的 bind_shop_info 调用 ---
+ # 使用 asyncio.to_thread (Python 3.9+) 或 run_in_executor 将同步函数移到线程池执行
+ # 限制并发线程数很重要,避免创建过多线程
+ max_workers = 10 # 限制线程池大小,根据系统性能调整
+ loop = asyncio.get_event_loop()
+
+ # 创建任务列表
+ tasks = []
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
+ for user_data in not_in_db_list:
+ # 将同步函数提交到线程池执行,并返回一个 Future
+ # asyncio.run_in_executor 将 Future 包装成 awaitable 的协程
+ task = loop.run_in_executor(executor, sync_fetch_bind_info, user_data)
+ tasks.append(task)
+
+ # 等待所有线程池任务完成
+ all_bind_lists_results = await asyncio.gather(*tasks, return_exceptions=True)
+
+ # 处理结果,将异常和正常结果分开
+ all_users_to_process = []
+ for result in all_bind_lists_results:
+ if isinstance(result, Exception):
+ logger.exception(f"Thread pool task failed: {result}")
+ # 可以选择跳过或记录错误
+ else:
+ all_users_to_process.extend(result)
+
+ # --- 去重逻辑 ---
+ unique_users_to_process = {}
+ for user in all_users_to_process:
+ customer_id = user.get("customer_id", 0)
+ tenant_name = user.get("tenant_name", 0)
+ platform = user.get("platform", 0)
+
+ key = f"{customer_id}_{tenant_name}_{platform}"
+ if key not in unique_users_to_process:
+ unique_users_to_process[key] = user
+
+ print(f"去重后待处理用户数: {len(unique_users_to_process)}", flush=True)
+
+ # --- 并发处理去重后的用户 ---
+ # 这部分可以保持原有逻辑,因为它已经是并发的了
+ # 但要注意,如果同时有多个任务尝试插入相同的 customer_id,可能会有并发问题
+ # 可以考虑使用数据库的 INSERT IGNORE 或 ON DUPLICATE KEY UPDATE 等特性
+
+ async def process_user(user_obj: Dict[str, Any]):
+ try:
+ user_type = user_obj.get("type", 0)
+ tenant_name = user_obj.get("tenant_name", 0)
+ customer_id = user_obj.get("customer_id", 0)
+ platform = user_obj.get("platform", 0)
+
+ if user_type == 'staff':
+ print(f'绑定员工:{user_obj}', flush=True)
+ else:
+ print(f'绑定客户:{user_obj}, Customer ID: {customer_id}', flush=True)
+ # --- 检查数据库中是否已存在绑定 ---
+ existing_bind = await CrmBindInfo.filter(customer_id=customer_id, platform=platform, tenant_name=tenant_name).first()
+ if not existing_bind:
+ bind_instance = CrmBindInfo.create_bind(user_obj)
+ await bind_instance.save()
+ print(f"客户 {customer_id} 绑定成功", flush=True)
+ else:
+ print(f"客户 {customer_id} 已存在绑定", flush=True)
+ logger.warning(f"Attempted to bind customer_id {customer_id} which already exists in CrmBindInfo.")
+ except Exception as e:
+ logger.exception(e)
+ print(f"处理用户 {user_obj.get('customer_id', 'unknown')} 时出错: {e}", flush=True)
+
+ max_concurrent_db_tasks = 10 # 可以独立控制数据库操作的并发数
+ semaphore = asyncio.Semaphore(max_concurrent_db_tasks)
+
+ async def process_user_with_semaphore(user_obj):
+ async with semaphore:
+ return await process_user(user_obj)
+
+ tasks = [process_user_with_semaphore(user_obj) for user_obj in unique_users_to_process.values()]
+ await asyncio.gather(*tasks, return_exceptions=True)
+
+ print("当前批次所有用户绑定任务完成", flush=True)
+
+ async def bind_crm_all_customer(self):
+ """
+ 绑定CRM客户和微信用户
+ """
+
+ await self.bind_crm_all_customer_by_db()
+
+ for shop in binder_service.iter_shop():
+ shopId = shop.get("shopId", "")
+ shopName = shop.get("shopName", "")
+ print(f"开始处理店铺:{shopName}", flush=True)
+
+ not_in_db_list = []
+
+ shop_info = {
+ 'shopId': shopId,
+ 'shopName': shopName,
+ }
+
+ is_done = False
+ user_list_iter = binder_service.iter_list_trade_user(binder_service.xy_client, shopId=shopId)
+ for user_list in split_generator(user_list_iter):
+ if is_done: break
+ # 提取所有待检查的 cid
+ all_cids = [str(user.get("cid")) for user in user_list]
+ # print(user_list[0])
+ # break
+
+ # 只查询 user_list 中存在的 cid
+ in_objs = await self.model.filter(cid__in=all_cids)
+
+ # 提取已存在的 cid
+ in_db_cid_set = {user.cid for user in in_objs}
+
+ print(f'已处理用户数:{len(in_db_cid_set)}', flush=True)
+
+ # 筛选待处理用户
+ not_in_db_list = [user for user in user_list if user.get("cid") not in in_db_cid_set]
+ for user in not_in_db_list:
+ id = user.pop("id", None)
+ # print('user', user)
+
+ # if not not_in_db_list:
+ # is_done = True
+ # break
+
+ print(f'待处理用户数:{len(not_in_db_list)}', flush=True)
+
+ if not_in_db_list:
+ model_list = [self.model(**CrmCustomerCreate(**user).model_dump(exclude_unset=True)) for user in not_in_db_list]
+ await self.model.bulk_create(model_list)
+
+ if not not_in_db_list:
+ print(f'店铺:{shopName},无待处理用户', flush=True)
+ continue
+
+ await self.bind_crm_all_customer_by_db()
+
+ async def bind_user(self, bind_info: CrmBindInfoCreate):
+ """
+ 绑定用户
+ """
+ bind_info_dict = bind_info.model_dump(exclude_unset=True)
+ platform = bind_info_dict.get("platform", None)
+ platform_id = bind_info_dict.get("platform_id", None)
+ bind_info_dict.pop("id", None)
+
+ existing_bind = await CrmBindInfo.filter(platform=platform, platform_id=platform_id).first()
+ if existing_bind:
+ await CrmBindInfo.filter(id=existing_bind.id).update(**bind_info_dict)
+ else:
+ obj = CrmBindInfo(**bind_info_dict)
+ await obj.save()
+ return {"message": "用户绑定成功"}
+
+crm_customer_controller = CrmCustomerController()
diff --git a/app/controllers/datasource.py b/app/controllers/datasource.py
new file mode 100644
index 0000000..29b9dc9
--- /dev/null
+++ b/app/controllers/datasource.py
@@ -0,0 +1,41 @@
+from typing import List
+
+from app.core.crud import CRUDBase
+from app.models.admin import Api, Menu, Datasource
+from app.schemas.codegen import DatasourceCreate, DatasourceUpdate, DatasourceInfo
+from app.utils.db import DatabaseInfo
+
+class DatasourceController(CRUDBase[Datasource, DatasourceCreate, DatasourceUpdate]):
+ def __init__(self):
+ super().__init__(model=Datasource)
+
+ async def load_tables(self, name: str) -> list[DatasourceInfo]:
+ # datasource_obj = await self.model.filter(name=name).first()
+ # if not datasource_obj:
+ # raise HTTPException(status_code=400, detail="数据源不存在")
+
+ with DatabaseInfo(
+ host="lt.330770.xyz",
+ port=3307,
+ user="root",
+ password="rap_sky",
+ database="rpa"
+ ) as db:
+ tables = db.get_all_tables()
+
+ return tables
+
+
+ async def update_datasources(self, datasource: Datasource, menu_ids: List[int], api_infos: List[dict]) -> None:
+ await datasource.menus.clear()
+ for menu_id in menu_ids:
+ menu_obj = await Menu.filter(id=menu_id).first()
+ await datasource.menus.add(menu_obj)
+
+ await datasource.apis.clear()
+ for item in api_infos:
+ api_obj = await Api.filter(path=item.get("path"), method=item.get("method")).first()
+ await datasource.apis.add(api_obj)
+
+
+datasource_controller = DatasourceController()
diff --git a/app/controllers/dept.py b/app/controllers/dept.py
new file mode 100644
index 0000000..0dbb5fa
--- /dev/null
+++ b/app/controllers/dept.py
@@ -0,0 +1,86 @@
+from tortoise.expressions import Q
+from tortoise.transactions import atomic
+
+from app.core.crud import CRUDBase
+from app.models.admin import Dept, DeptClosure
+from app.schemas.depts import DeptCreate, DeptUpdate
+
+
+class DeptController(CRUDBase[Dept, DeptCreate, DeptUpdate]):
+ def __init__(self):
+ super().__init__(model=Dept)
+
+ async def get_dept_tree(self, name):
+ q = Q()
+ # 获取所有未被软删除的部门
+ q &= Q(is_deleted=False)
+ if name:
+ q &= Q(name__contains=name)
+ all_depts = await self.model.filter(q).order_by("order")
+
+ # 辅助函数,用于递归构建部门树
+ def build_tree(parent_id):
+ return [
+ {
+ "id": dept.id,
+ "name": dept.name,
+ "desc": dept.desc,
+ "order": dept.order,
+ "parent_id": dept.parent_id,
+ "children": build_tree(dept.id), # 递归构建子部门
+ }
+ for dept in all_depts
+ if dept.parent_id == parent_id
+ ]
+
+ # 从顶级部门(parent_id=0)开始构建部门树
+ dept_tree = build_tree(0)
+ return dept_tree
+
+ async def get_dept_info(self):
+ pass
+
+ async def update_dept_closure(self, obj: Dept):
+ parent_depts = await DeptClosure.filter(descendant=obj.parent_id)
+ for i in parent_depts:
+ print(i.ancestor, i.descendant)
+ dept_closure_objs: list[DeptClosure] = []
+ # 插入父级关系
+ for item in parent_depts:
+ dept_closure_objs.append(DeptClosure(ancestor=item.ancestor, descendant=obj.id, level=item.level + 1))
+ # 插入自身x
+ dept_closure_objs.append(DeptClosure(ancestor=obj.id, descendant=obj.id, level=0))
+ # 创建关系
+ await DeptClosure.bulk_create(dept_closure_objs)
+
+ @atomic()
+ async def create_dept(self, obj_in: DeptCreate):
+ # 创建
+ if obj_in.parent_id != 0:
+ await self.get(id=obj_in.parent_id)
+ new_obj = await self.create(obj_in=obj_in)
+ await self.update_dept_closure(new_obj)
+
+ @atomic()
+ async def update_dept(self, obj_in: DeptUpdate):
+ dept_obj = await self.get(id=obj_in.id)
+ # 更新部门关系
+ if dept_obj.parent_id != obj_in.parent_id:
+ await DeptClosure.filter(ancestor=dept_obj.id).delete()
+ await DeptClosure.filter(descendant=dept_obj.id).delete()
+ await self.update_dept_closure(dept_obj)
+ # 更新部门信息
+ dept_obj.update_from_dict(obj_in.model_dump(exclude_unset=True))
+ await dept_obj.save()
+
+ @atomic()
+ async def delete_dept(self, dept_id: int):
+ # 删除部门
+ obj = await self.get(id=dept_id)
+ obj.is_deleted = True
+ await obj.save()
+ # 删除关系
+ await DeptClosure.filter(descendant=dept_id).delete()
+
+
+dept_controller = DeptController()
diff --git a/app/controllers/finance_parse/__init__.py b/app/controllers/finance_parse/__init__.py
new file mode 100644
index 0000000..166621b
--- /dev/null
+++ b/app/controllers/finance_parse/__init__.py
@@ -0,0 +1,43 @@
+from .finance_parse import parse_finance_data
+from tortoise.expressions import Q
+from tortoise.transactions import atomic
+
+from app.models.automation import Task, TaskStatus
+from app.schemas.task import TaskModel, DecodeTaskParams, DecodeTaskResult
+
+class TaskController(object):
+ async def get_task(self, name, type):
+ q = Q()
+ # 获取所有未被软删除的任务
+ q &= Q(is_deleted=False)
+ if name: q &= Q(name__contains=name)
+ if type: q &= Q(type=type)
+ all_tasks = await self.model.filter(q).order_by("id")
+ return all_tasks
+
+ async def list(self, name, type, page: int = 1, page_size: int = 10, order: list[str] = ["id"]):
+ q = Q()
+ if name: q &= Q(name__contains=name)
+ if type: q &= Q(type=type)
+ query = Task.filter(q)
+ return await query.count(), await query.offset((page - 1) * page_size).limit(page_size).order_by(*order)
+
+ @atomic()
+ async def create_task(self, name: str, obj_in: DecodeTaskParams):
+ task_obj = TaskModel.create_decode_task(name, obj_in)
+ task_obj.result = task_obj.result or {}
+ obj = Task(**task_obj.model_dump())
+ await obj.save()
+ return obj, task_obj
+
+ @atomic()
+ async def update_task(self, obj_in: TaskModel, task_id: int, status: TaskStatus, result: DecodeTaskResult):
+ task_obj = await Task.get(id=task_id)
+ obj_in.status = status
+ obj_in.set_result(result)
+
+ # 更新任务信息
+ task_obj.update_from_dict(obj_in.model_dump(exclude_unset=True))
+ await task_obj.save()
+
+task_controller = TaskController()
diff --git a/app/controllers/finance_parse/finance_parse.py b/app/controllers/finance_parse/finance_parse.py
new file mode 100644
index 0000000..de26b9b
--- /dev/null
+++ b/app/controllers/finance_parse/finance_parse.py
@@ -0,0 +1,616 @@
+# spacy_training/scripts/predict.py
+import re
+import cn2an
+import os.path
+from tqdm import tqdm
+from pathlib import Path
+
+
+# 单位与连接符定义
+MAX_ITEM_OFFSET = 10
+UNIT_PATTERN = "个|张|片|块|条|幅|根"
+SIZE_UNITS_PATTERN = r'mm|cm|dm|m'
+SIZE_JOIN_PATTERN = "xX×"
+NUMS_PATTERN = r'\d+(?:\.\d+)' # 修复:原缺少 ? 导致整数不匹配
+CNT_PATTERN = rf'{NUMS_PATTERN}?[^克+\s\d-]*'
+EXCEPT_TEXT = "解析失败"
+INDEX_COL = '序号'
+
+base_colume = ['解析备注', '尺寸备注', '描述', '异常信息']
+mapping_size = {
+ "width_mm": "长",
+ "height_mm": "宽",
+ # "original_size": "原始尺寸",
+ # "total_quantity": "总数量",
+ "style_count": "款数",
+ "quantity_per_style": "数量",
+ "unit": "单位",
+ "描述": "描述",
+ "解析备注": "解析备注",
+ "size_unit": "尺寸单位",
+ "exception_msg": '异常信息'
+ }
+
+import spacy
+import pandas as pd
+
+# 将 doc 转换为字典
+def doc_to_dict(doc):
+ return {
+ "text": doc.text,
+ "entities": [
+ {
+ "text": ent.text,
+ "label": ent.label_,
+ "start": ent.start_char,
+ "end": ent.end_char
+ }
+ for ent in doc.ents
+ ],
+ }
+
+def predict_file(df, model_path="./spacy_training/model", key='备注'):
+ print(model_path)
+ nlp = spacy.load(model_path)
+ if "sentencizer" not in nlp.pipe_names:
+ nlp.add_pipe("sentencizer")
+
+ # 统一表头空格
+ df.columns = df.columns.str.strip()
+ key = key.strip()
+
+ for i, row in tqdm(df.iterrows(), total=len(df)):
+ order = str(row[key]).strip()
+ base_row = {key: val for key, val in row.items()} # 复制原行数据
+ yield order, base_row, doc_to_dict(nlp(order))
+ # doc = nlp(text)
+ # print(f"\n🔤 文本: {text}")
+ # import json; print(json.dumps(doc_to_dict(doc), indent=4, ensure_ascii=False))
+
+ # break
+
+from dataclasses import dataclass
+
+@dataclass
+class StructuredOrder:
+ key: str
+ width_mm: float
+ height_mm: float
+ original_size: str
+ style_count: int
+ quantity_per_style: float
+ unit: str
+ total_quantity: float
+ original_qty: str
+ size_position: list
+ qty_position: list
+ exception_msg: str
+ calc_type: str
+ size_unit: str
+ meta: dict
+
+def expand_to_structured(raw_pairs, size_unit=''):
+ unit_map = {
+ '百': 100, '千': 1000, '万': 10000,
+ '百万': 1000000, '千万': 10000000, '亿': 100000000,
+ }
+
+ UNIT_REGEX = re.compile(rf'({UNIT_PATTERN})$')
+ exception_msg = []
+
+ def extract_multiplier(text):
+ """
+ 从字符串中提取数值乘数,支持:
+ - 阿拉伯数字 + 中文单位:3.5万 → 35000
+ - 纯中文数字 + 单位:三万五千 → 35000
+ - 纯中文:两千万 → 20000000
+ """
+ # 去除末尾单位(保留前面的部分)
+ clean = UNIT_REGEX.sub('', text.strip())
+
+ # 如果去单位后为空,原字符串可能是纯单位(如“万”),默认系数为1
+ if not clean:
+ matched_unit = UNIT_REGEX.search(text)
+ if matched_unit:
+ unit = matched_unit.group(1)
+ return unit_map.get(unit, 1.0)
+ return 1.0
+
+ # 尝试直接转阿拉伯数字
+ try:
+ return float(clean)
+ except ValueError:
+ pass
+
+ # 尝试转中文数字(如“三万五千”虽然不合理,但“三万”可以)
+ try:
+ # 注意:cn2an 可以直接处理“三万五千”这种
+ num = cn2an.cn2an(clean, "smart") # smart 模式支持混合写法
+ if isinstance(num, (int, float)):
+ # 检查原字符串是否有单位后缀(比如“三万”中的“万”已被去除,需补回)
+ matched_unit = UNIT_REGEX.search(text)
+ if matched_unit:
+ unit = matched_unit.group(1)
+ num *= unit_map[unit]
+ return float(num)
+ except Exception:
+ exception_msg.append(f"尺寸解析异常:{text}")
+ pass
+
+ return 1.0
+
+ def parse_size(size_str):
+ join_str = ''.join(re.findall(f"[{SIZE_JOIN_PATTERN}]+", size_str))
+ if len(join_str) == 0 or re.search('比例|等高|等比|等宽', size_str):
+ result = f'异常值:{size_str}'
+ return result, result, size_unit
+ size_str = size_str.replace(join_str, "x")
+ match = re.match(rf'({NUMS_PATTERN}?)([a-z]+)?[{SIZE_JOIN_PATTERN}]({NUMS_PATTERN}?)([a-z]+)?', size_str, re.IGNORECASE)
+ if not match:
+ return None, None, size_unit
+ w_val, w_unit, h_val, h_unit = match.groups()
+ unit = (w_unit or h_unit or size_unit).lower()
+ conv = {'m': 1000, 'dm': 100, 'cm': 10, 'mm': 1}
+ if unit not in conv:
+ print(f'未知单位: {unit} size_str:{size_str} size_unit:{size_unit}')
+ exception_msg.append(f"尺寸解析异常:{size_str}, 未知单位: {unit}")
+ return None, None, unit
+ w_mm = conv[unit] * float(w_val)
+ h_mm = conv[unit] * float(h_val)
+ return round(w_mm, 3), round(h_mm, 3), unit
+
+ def parse_quantity(qty_str):
+ qty_str = "".join(qty_str.split())
+ multi_match = re.match((
+ rf'[共计]?(.+?)款\w*?([共计]?[\d.一二两三四五六七八九十百千万亿]+)({UNIT_PATTERN})'
+ ), qty_str, re.IGNORECASE)
+ if multi_match:
+ style_part, qty_part = multi_match.group(1), multi_match.group(2)
+ if qty_part[0] in '共计':
+ qty_part = qty_part[1:]
+ calc_type = 'total'
+ else:
+ calc_type = 'single'
+ style_count = int(extract_multiplier(style_part))
+ quantity_per_style = int(extract_multiplier(qty_part))
+ unit = multi_match.group(3)
+
+ return {"style_count": style_count, "quantity_per_style": quantity_per_style, "unit": unit, "calc_type": calc_type}
+
+ single_match = re.match(rf'(.+?)({UNIT_PATTERN})', qty_str, re.IGNORECASE)
+ if single_match:
+ qty_part = single_match.group(1)
+ if qty_part[0] in '共计':
+ qty_part = qty_part[1:]
+ calc_type = 'total'
+ else:
+ calc_type = 'single'
+ quantity_per_style = int(extract_multiplier(qty_part))
+ unit = single_match.group(2)
+ return {"style_count": 1, "quantity_per_style": quantity_per_style, "unit": unit, "calc_type": calc_type}
+
+ return {"style_count": 0, "quantity_per_style": 0, "unit": "个"}
+
+ structured = []
+
+ for size_info, qty_info in raw_pairs:
+ s_start, s_end, s_text, *_ = size_info
+ q_start, q_end, q_text, *_ = qty_info
+
+ if s_text == EXCEPT_TEXT:
+ width_mm, height_mm = EXCEPT_TEXT, EXCEPT_TEXT
+ else:
+ size_parsed = parse_size(s_text)
+ if size_parsed:
+ width_mm, height_mm, size_unit = size_parsed
+ else:
+ width_mm, height_mm, size_unit = None, None, None
+
+ if q_text == EXCEPT_TEXT:
+ style_count, quantity_per_style, unit = 0, EXCEPT_TEXT, EXCEPT_TEXT
+ else:
+ qty_parsed = parse_quantity(q_text)
+ style_count, quantity_per_style, unit = qty_parsed["style_count"], qty_parsed["quantity_per_style"], qty_parsed["unit"]
+
+ structured.append(StructuredOrder(
+ key=f"{s_start}{s_end}{s_text}",
+ width_mm=width_mm,
+ height_mm=height_mm,
+ original_size=s_text,
+ style_count=style_count,
+ quantity_per_style=quantity_per_style,
+ unit=unit,
+ total_quantity=style_count * quantity_per_style if style_count and style_count else None,
+ original_qty=q_text,
+ size_position=[s_start, s_end],
+ qty_position=[q_start, q_end],
+ exception_msg="; ".join(exception_msg),
+ calc_type=qty_parsed.get('calc_type', None),
+ size_unit=size_unit,
+ meta={
+ "size_text_except": s_text if s_text == EXCEPT_TEXT or (not width_mm and not height_mm) else None,
+ "qty_text_except": q_text if q_text == EXCEPT_TEXT else None,
+ }
+ ))
+
+ return structured
+
+
+def decode(doc_dict_list, is_horizontal=False):
+ ent_group = {
+ # "单号": ["单号"],
+ "刮刮膜尺寸": ["刮刮膜尺寸"],
+ # "产品": ["产品"],
+ # "材质": ["材质"],
+ # "工艺": ["工艺"],
+ # "用户": ["用户信息"],
+ # "加急": ["加急"],
+ "数量": ["尺寸", "数量"],
+ }
+ columns = list(ent_group.keys())
+ columns.remove('数量')
+ columns.extend(base_colume)
+ group_reflect = {v: k for k, vs in ent_group.items() for v in vs}
+ result_items = []
+ exception_list = []
+
+ for doc_dict in doc_dict_list:
+ sentence = doc_dict["text"]
+ entities = doc_dict["entities"]
+
+ is_except = False
+
+ # 强关联实体需要进行组合处理
+ # ======================================== 实体分组 ========================================
+ ent_group_dict = {k: [] for k in ent_group.keys()}
+ base_ent_group_dict = {}
+
+ for ent in entities:
+ start, end, label, text = ent["start"], ent["end"], ent["label"], ent["text"]
+ group_name = group_reflect[label]
+ ent_group_dict[group_name].append((start, end, text, label))
+ base_ent_group_dict[group_name] = text
+
+ result = []
+
+ # 解析出尺寸和数量的组合
+ # ======================================== 解析尺寸和数量 ========================================
+ group_list = []
+ size_and_qty_list = [[], []]
+
+ # 数量需要和尺寸进行组合处理
+ base_ent_group_dict.pop("数量", None)
+ for cnt_item in ent_group_dict["数量"]:
+ size_info, qty_info = size_and_qty_list
+ label = cnt_item[-1]
+ if label == '数量':
+ qty_info.append(cnt_item)
+ elif label == '尺寸':
+ start, end, text, _label = cnt_item
+ if f'-({text[:2]}' in sentence:
+ continue
+ if qty_info:
+ group_list.append(size_and_qty_list)
+ size_and_qty_list = [[], []]
+ size_info, qty_info = size_and_qty_list
+ size_info.append(cnt_item)
+
+ # 存在数量和尺寸
+ if size_and_qty_list[0] or size_and_qty_list[1]:
+ group_list.append(size_and_qty_list)
+
+ # 如果只有一对尺寸,有可能反过来描述
+ if len(group_list) == 2:
+ _1, qty_info = group_list[0]
+ size_info, _2 = group_list[1]
+
+ if not _1 and not _2:
+ group_list[:] = [[qty_info, size_info]]
+
+ # print(f'订单中尺寸和数量组合:{group_list}')
+
+ # 解析出尺寸和数量的组合
+ # ======================================== 解析长、宽、款数、数量 ========================================
+ size_unit = ''
+ for size_info, qty_info in group_list:
+ units = set(''.join(re.findall(SIZE_UNITS_PATTERN, ent[2])) for ent in size_info)
+ units = [unit for unit in units if unit]
+ if len(units) == 1:
+ size_unit = units[0]
+
+ size_and_qty_parsed_result = []
+ for size_info, qty_info in group_list:
+ size_len, qty_len = len(size_info), len(qty_info)
+ if size_len == 0:
+ print(f'解析异常,尺寸为空,数量为{qty_info},原文:{sentence}', entities)
+ is_except = True
+ # 起始位置、结束位置、尺寸文本、尺寸标签
+ qty_info = [(0, 0, EXCEPT_TEXT, '数量')]
+ if qty_len == 0:
+ print(f'解析异常,数量为空,尺寸为{size_info},原文:{sentence}', entities)
+ is_except = True
+ # 起始位置、结束位置、尺寸文本、尺寸标签
+ size_info = [(0, 0, EXCEPT_TEXT, '尺寸')]
+ # continue
+
+ # print('size_info, qty_info', size_info, qty_info)
+ if size_len == qty_len:
+ decode_desc = "单尺寸、单款式描述的订单" if size_len == 1 else "多尺寸、多款式描述一一匹配的订单"
+
+ for size, qty in zip(size_info, qty_info):
+ expand = expand_to_structured([(size, qty)], size_unit) or []
+ exceptions = [item.meta for item in expand if item.meta.get('size_text_except') or item.meta.get('qty_text_except')]
+ if exceptions: print("解析异常", exceptions, '原文', sentence, entities)
+ for item in expand:
+ parsed_item = item.__dict__.copy()
+ parsed_item['描述'] = f"{size[2]}|{qty[2]}"
+ parsed_item['解析备注'] = decode_desc
+ parsed_item['size_len'] = size_len
+ parsed_item['qty_len'] = qty_len
+ size_and_qty_parsed_result.append(parsed_item)
+ elif size_len>1 and qty_len>1:
+
+ if size_len > qty_len:
+ total_qty = []
+ for size, qty in zip(size_info, qty_info):
+ expand = expand_to_structured([(size, qty)], size_unit) or []
+ for item in expand:
+ total_qty.append(item.style_count)
+
+ if size_len == sum(total_qty):
+ decode_desc = "多尺寸、多款式描述:多款式描述和尺寸相等的订单"
+ item = []
+ for idx, qty in enumerate(total_qty):
+ item.extend(qty_info[idx] for _ in range(qty))
+ print('解析:', size_info, qty_info, item)
+ # breakpoint()
+ qty_info = item
+ qty_len = len(qty_info)
+ else:
+ # qty_info = []
+ decode_desc = f"匹配异常:多尺寸、多款式描述的订单: {size_info} {qty_info}"
+ else:
+ # qty_info = []
+ decode_desc = f"匹配异常:多尺寸、多款式描述的订单: {size_info} {qty_info}"
+
+ for size, qty in zip(size_info, qty_info):
+ expand = expand_to_structured([(size, qty)], size_unit) or []
+ exceptions = [item.meta for item in expand if item.meta.get('size_text_except') or item.meta.get('qty_text_except')]
+ if exceptions: print("解析异常", exceptions, '原文', sentence, entities)
+ for item in expand:
+ parsed_item = item.__dict__.copy()
+ parsed_item['style_count'] = 1
+ parsed_item['描述'] = f"{size[2]}|{qty[2]}"
+ parsed_item['解析备注'] = decode_desc
+ parsed_item['size_len'] = size_len
+ parsed_item['qty_len'] = qty_len
+ size_and_qty_parsed_result.append(parsed_item)
+ else:
+ # 多尺寸-单数量、单尺寸-多数量 告警多尺寸-多数量
+ iter_item = ((size, qty) for size in size_info for qty in qty_info)
+ if size_len > 1 and qty_len == 1:
+ decode_desc = "多尺寸、单款式描述的订单"
+ elif size_len == 1 and qty_len > 1:
+ decode_desc = "单尺寸、多款式描述的订单"
+ else:
+ decode_desc = f"异常:多尺寸、多款式描述的订单: {size_info} {qty_info}"
+
+ for item in iter_item:
+ size, qty = item
+ expand = expand_to_structured([(size, qty)], size_unit) or []
+ exceptions = [item.meta for item in expand if item.meta.get('size_text_except') or item.meta.get('qty_text_except')]
+ if exceptions: print("解析异常", exceptions, '原文', sentence, entities)
+ for item in expand:
+ parsed_item = item.__dict__.copy()
+ parsed_item['描述'] = f"{size[2]}|{qty[2]}"
+ parsed_item['解析备注'] = decode_desc
+ parsed_item['size_len'] = size_len
+ parsed_item['qty_len'] = qty_len
+ size_and_qty_parsed_result.append(parsed_item)
+
+ result.extend(size_and_qty_parsed_result)
+
+ for item in result:
+ # print('item', item)
+ new_item = base_ent_group_dict.copy()
+ for key in mapping_size:
+ col_name = mapping_size[key]
+ new_item[col_name] = item[key]
+ columns.append(col_name)
+ size_len = item.pop('size_len', None)
+ qty_len = item.pop('qty_len', None)
+ calc_type = item.pop('calc_type', None)
+
+ if size_len > 1 and calc_type == 'total':
+ new_item[f'数量'] = f"警告:异常值(总计值:{new_item[f'数量']})"
+
+ if size_len == 1 or size_len == qty_len:
+ pass
+ # new_item[f'解析备注'] = '单尺寸的订单'
+ else:
+ if size_len == new_item['款数']:
+ new_item[f'款数'] = 1
+ # new_item[f'解析备注'] = '尺寸数量和款数相同的订单'
+ elif new_item['款数'] == 1:
+ new_item[f'款数'] = 1
+ # new_item[f'解析备注'] = '单款的订单'
+ else:
+ new_item[f'款数'] = "异常值:款数和尺寸数不一致"
+ new_item[f'数量'] = "异常值:款数和尺寸数不一致"
+ # new_item[f'解析备注'] = '多尺寸并且和款数不同的订单'
+
+ result_items.append(new_item)
+ # print(json.dumps(new_item, indent=4, ensure_ascii=False))
+
+ if not result:
+ if base_ent_group_dict:
+ base_ent_group_dict['是否异常'] = is_except
+ base_ent_group_dict[f'解析备注'] = '未解析到实际尺寸的订单1'
+ result_items.append(base_ent_group_dict.copy())
+ else:
+ result_items.append({
+ "是否异常": is_except,
+ "解析备注": "未解析到任何实体"
+ })
+
+ return exception_list, columns, result_items
+
+
+def parse_finance_data(file_path, target_index, is_horizontal, sheet_name="Sheet1"):
+
+ df = pd.read_excel(file_path, dtype=str, sheet_name=sheet_name)
+ total_amount = len(df)
+
+ items = predict_file(df, './app/resources/models/订单尺寸识别/best_model', target_index)
+ # items = predict_file(file_path, './resources/models/lintao/best_model', target_index)
+ exception_list, results, outputs_columns = [], [], []
+ origin_item = None
+
+ parsed_dict = {}
+ mapping_size_values = mapping_size.values()
+
+ for text, origin_item, item in items:
+
+ decode_result = []
+
+ base_info = {}
+ result_index = parsed_dict.get(f"{text}_index")
+ if result_index:
+ exceptions, columns = [], []
+ try:
+ decode_item = parsed_dict[text][result_index]
+ for key in origin_item:
+ if key in mapping_size_values:
+ continue
+ decode_item[key] = origin_item[key]
+ decode_item['尺寸备注'] = '多组尺寸解析'
+ parsed_dict[f"{text}_index"] += 1
+ continue
+ except IndexError:
+ print(f'解析到的尺寸不足:{text},{result_index}, {parsed_dict[text]}')
+ base_item = parsed_dict[text][0]
+ for col in base_colume:
+ base_info[col] = base_item.get(col, '')
+ base_info["长"] = '异常值:没有解析到那么多尺寸'
+
+ decode_items = []
+ parsed_dict[f"{text}_index"] += 1
+ else:
+ exceptions, columns, decode_items = decode([item], is_horizontal) or []
+
+ # if '(250731202428388557)' in text:
+ # print(result_index, text, exceptions, columns, decode_items)
+ # breakpoint()
+
+ if len(columns) > len(outputs_columns):
+ outputs_columns = columns
+
+ if len(decode_items) > 1:
+ for item in decode_items:
+ item['尺寸备注'] = '多组尺寸解析'
+
+ for idx, decode_item in enumerate(decode_items):
+ base_row = origin_item.copy()
+ if idx and INDEX_COL in base_row: base_row[INDEX_COL] = ''
+ # base_row = origin_item.copy() if idx == 0 else {}
+ base_row.update(decode_item)
+ is_except = base_row.pop('是否异常', False)
+ if is_except:
+ for col in base_colume:
+ origin_item[col] = base_row.get(col, '')
+ decode_result.append(origin_item)
+ exception_list.append(base_row)
+ else:
+ decode_result.append(base_row)
+ if not decode_items:
+ if base_info:
+ origin_item.update(base_info)
+
+ decode_result.append(origin_item)
+ else:
+ if is_horizontal:
+ new_decode_result = decode_result[0].copy()
+ for idx, item in enumerate(decode_result[1:], 1):
+ for key in mapping_size_values:
+ new_key = f"{key}{idx}"
+ new_decode_result[new_key] = item[key]
+ if new_key not in outputs_columns:
+ outputs_columns.append(new_key)
+ decode_result = [new_decode_result]
+
+ if text not in parsed_dict:
+ parsed_dict[text] = decode_result
+ parsed_dict[f"{text}_index"] = 0
+ parsed_dict[f"{text}_index"] += 1
+
+ # if decode_result:
+ # results.append(decode_result[0])
+ if len(decode_items) > 1:
+ for item in decode_result[1:]:
+ item['尺寸备注'] = '复制新增:多组尺寸解析'
+ results.extend(decode_result)
+
+ # break
+
+ print('横向解析', outputs_columns)
+ if origin_item:
+ # outputs_columns.
+ _outputs_columns = list(origin_item.keys()) + outputs_columns
+ col_set = set()
+ outputs_columns = []
+
+ for col in _outputs_columns:
+ if col not in col_set:
+ col_set.add(col)
+ outputs_columns.append(col)
+
+ df = pd.DataFrame(results, columns=outputs_columns)
+
+ file_name = os.path.basename(file_path).replace(".xlsx", '')
+ filename = file_name + '_解析' + ('_横向排列' if is_horizontal else '_纵向排列')
+
+ upload_dir = Path("outputs")
+ upload_dir.mkdir(parents=True, exist_ok=True)
+ save_file_path = upload_dir / f'{filename}_1.xlsx'
+
+ # 使用 ExcelWriter 同时写入多个 sheet
+ with pd.ExcelWriter(save_file_path, engine='openpyxl') as writer:
+ df.to_excel(writer, sheet_name='正常解析', index=False)
+ if exception_list:
+ except_df = pd.DataFrame(exception_list, columns=outputs_columns)
+ except_df.to_excel(writer, sheet_name='异常解析', index=False)
+
+ print(f'解析结果保存在:{save_file_path}')
+ return save_file_path, total_amount
+
+
+if __name__ == "__main__":
+
+ # print(expand_to_structured([[(0,1,'600x50cm','r'), (0,1,'3款各1张','e')]]))
+ # exit()
+ # predict("Apple is opening a new office in Tokyo.")
+ # predict("Google hired Sarah Connor from Berlin last year.")
+ file_info = ['d:/会计组/新领图8月.xlsx', '备注']
+ # file_info = ['d:/会计组/即客2025年8月尺寸整理.xlsx', '系统文件名']
+ file_info = ['d:/会计组/8月订单明细9.6.xlsx', '文件名']
+ file_info = ['d:/会计组/ZHX-8月订单明细.xlsx', '文件名']
+ file_info = ['d:/会计组/国税数据源/智韬2025年8月尺寸整理(1).xlsx', '系统文件名']
+ file_info = ['d:/会计组/彩印通8月数码9.23.xlsx', 'ERP系统文件名']
+ file_info = ['d:/会计组/CYT8月明细9.24.xlsx', 'ERP系统文件名']
+ file_info = ['d:/会计组/9.1-9.26.xlsx', '备注']
+ file_info = ['d:/会计组/JD.xlsx', '文件名']
+ file_info = ['d:/会计组/泰州即客2025年9月尺寸整理.xlsx', '系统文件名']
+ file_info = ['d:/会计组/ZHX需拆明细9月.xlsx', '文件名', False]
+ file_info = ['d:/会计组/七彩2024年9月账单尺寸整理.xlsx', '系统文件名', True]
+ file_info = ['d:/会计组/艾印图文2024年9月账单尺寸整理.xlsx', '系统文件名', True]
+ file_info = ['d:/会计组/9月转印.xlsx', 'erp', True]
+ file_info = ['d:/会计组/9月CYT.xlsx', '文件名', False]
+ file_info = ['d:/会计组/智韬2025年9月尺寸整理.xlsx', '系统文件名', True]
+ file_info = ['d:/会计组/9月UV转印贴.xlsx', '文件名', True]
+ file_info = ['d:/会计组/彩印通2025年9月(数码).xlsx', '文件名', True]
+ file_info = ['d:/会计组/9月名片.xlsx', '文件名', True]
+ file_info = ['d:/会计组/9月不干胶.xlsx', '文件名', True]
+ file_path, target_index, is_horizontal = file_info
+ parse_finance_data(file_path, target_index, is_horizontal)
+
+
\ No newline at end of file
diff --git a/app/controllers/finance_parse/requirements.txt b/app/controllers/finance_parse/requirements.txt
new file mode 100644
index 0000000..3d5ab79
--- /dev/null
+++ b/app/controllers/finance_parse/requirements.txt
@@ -0,0 +1,6 @@
+pandas==2.3.3
+spacy==3.8.7
+tqdm==4.67.1
+cn2an==0.5.23
+openpyxl==3.1.5
+aiomysql
diff --git a/app/controllers/menu.py b/app/controllers/menu.py
new file mode 100644
index 0000000..d42586a
--- /dev/null
+++ b/app/controllers/menu.py
@@ -0,0 +1,16 @@
+from typing import Optional
+
+from app.core.crud import CRUDBase
+from app.models.admin import Menu
+from app.schemas.menus import MenuCreate, MenuUpdate
+
+
+class MenuController(CRUDBase[Menu, MenuCreate, MenuUpdate]):
+ def __init__(self):
+ super().__init__(model=Menu)
+
+ async def get_by_menu_path(self, path: str) -> Optional["Menu"]:
+ return await self.model.filter(path=path).first()
+
+
+menu_controller = MenuController()
diff --git a/app/controllers/msg.py b/app/controllers/msg.py
new file mode 100644
index 0000000..139067c
--- /dev/null
+++ b/app/controllers/msg.py
@@ -0,0 +1,461 @@
+from tortoise.exceptions import IntegrityError
+from app.core.crud import CRUDBase
+from app.schemas.msg import (
+ MsgCreate,
+ MsgUpdate,
+ MsgNewOrder,
+ FollowFormErp,
+ MsgType
+)
+import httpx
+from app.models.msg import Msg, Follow
+from app.models.weixin import WeixinCustomer, WeixinUser
+from some_sdk.wk_weixin_sdk.apis.extern_user import get_external_user_chat_info
+from some_sdk.services.binder import lintao_client, feishu_client
+from some_sdk.lintao_sdk.biz.by_order import get_order_relative_user
+from some_sdk.feishu_sdk.apis.doc import batch_create as create_feishu_records
+
+from datetime import datetime, timedelta
+from app.utils.common import async_split_generator, split_generator, gen_random_str
+from typing import List
+import re
+
+import hashlib
+
+import logging
+logger = logging.getLogger(__name__)
+
+import asyncio
+_sync_to_feishu_lock = asyncio.Lock()
+
+class MsgController(CRUDBase[Msg, MsgCreate, MsgUpdate]):
+ def __init__(self):
+ super().__init__(model=Msg)
+
+ async def send_to_wexin(self, data:dict ):
+ # pass
+ url = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=57ba9cd5-2c62-43dd-bfea-78c7b4073128'
+
+ async with httpx.AsyncClient() as client:
+ resp = await client.post(url,
+ headers={"Content-Type": "application/json"},
+ json=data,
+ )
+ data = resp.json()
+ print(data)
+ return data
+
+ async def send_order_to_weixin(self, msg: Msg):
+ """
+ 极简订单通知:仅展示店铺、旺旺ID、金额、归属客服
+ """
+ url = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=57ba9cd5-2c62-43dd-bfea-78c7b4073128'
+
+ order_list = msg.detail or []
+ if not order_list:
+ logger.warning("订单详情为空,跳过发送")
+ return
+
+ order = order_list[0]
+ # users = order.get("users", [])
+ # buyer = next((u for u in users if u.get("role") == "buyer"), None)
+
+ # 关键字段
+ shop_name = order.get("shop_name", "未知店铺")
+ buyer_name = msg.title
+ # buyer_name = buyer.get("name") if buyer else ""
+ # buyer_name = buyer_name or msg.content.split(")", 1)[0].split("(")[1]
+
+ is_refund = order.get("is_refund", 0) > 0
+ msg_title = "💰 用户退款" if is_refund else "🛒 新订单"
+
+ real_payment = order.get("real_payment", "0.00")
+ qiwei_name = msg.owner_name or "未知客服"
+
+ # 极简 Markdown
+ markdown_content = f"""# {msg_title}\n
+
+**店铺**:{shop_name}
+**旺旺ID**:`{buyer_name}`
+**金额**:¥{real_payment}
+**企微客服**:{qiwei_name}
+**订单编号**:{order.get("trade_no", "未知订单号")}
+"""
+
+ data = {
+ "msgtype": "markdown",
+ "markdown": {
+ "content": markdown_content
+ }
+ }
+ result = await self.send_to_wexin(data)
+ logger.info(f"企业微信极简订单通知发送结果: {result}")
+
+
+ async def parse_order_msg(self, msg: Msg, send_set: set, msg_dict: dict):
+ title = msg.title
+ try:
+ if title not in send_set:
+ send_set.add(title)
+ # try: await self.send_to_wexin(msg)
+ owner_name = '、'.join([ (name or '').split('(')[0].split('vip客服-')[-1].split('-印刷定制')[0] for name in msg_dict[title] ])msg.owner_name = owner_name
+
+ try: await self.send_order_to_weixin(msg)
+ except Exception as e:
+ logger.error(f'发送订单消息到微信失败,{e}')
+ return
+
+ # await self.send_to_wexin(msg, mentioned_list=msg_dict[title])
+ logger.info(f'发送消息到微信成功,{msg.content}')
+
+ msg.is_send = True
+ msg.send_at = datetime.now()
+ await msg.save()
+ except Exception as e:
+ logger.error(f'发送消息到微信失败,{e}')
+
+ async def new_system_msg(self, title: str, content: str, owner_name: str = None):
+ await self.create(
+ dict(
+ hash_id=hashlib.md5(f'{title}{content}{owner_name}'.encode()).hexdigest(),
+ title=title,
+ content=content,
+ type=MsgType.SYSTEM,
+ owner_name=owner_name,
+ )
+ )
+
+ async def send_system_msg(self, msg: Msg):
+ msg.send_at = datetime.now()
+ markdown_content = f"""# 📣 系统通知 \n
+## {msg.title} \n
+{msg.content} \n
+
+{msg.send_at.strftime("%Y-%m-%d %H:%M:%S")}
+"""
+
+ data = {
+ "msgtype": "markdown",
+ "markdown": {
+ "content": markdown_content
+ }
+ }
+ result = await self.send_to_wexin(data)
+ logger.info(f"企业微信极简订单通知发送结果: {result}")
+
+ msg.is_send = True
+ await msg.save()
+
+ async def sync_msg(self):
+ msg_list = await self.model.filter(is_send=False)
+
+ msg_dict = {}
+ for msg in msg_list:
+ title = msg.title
+ msg_dict[title] = msg_dict.get(title, []) + [msg.owner_name]
+
+ send_set = set()
+ for msg in msg_list:
+ title = msg.title
+ if msg.type == MsgType.NEW_ORDER:
+ await self.parse_order_msg(msg, send_set, msg_dict)
+ elif msg.type == MsgType.SYSTEM:
+ await self.send_system_msg(msg)
+
+ async def fill_customer_info(self):
+ msg_orm_list = await self.model.filter(id__gt=306)
+
+ update_list = []
+ for msg in msg_orm_list:
+ detail = msg.detail or []
+ taobao_name = msg.title
+
+ detail_list = []
+ for order_item in detail:
+ customer_orm = await WeixinCustomer.filter(taobao_name=taobao_name).first()
+ if not customer_orm:
+ logger.warning(f'未找到淘宝用户{taobao_name}的企微客户')
+ continue
+
+ for user in order_item.get("users", []):
+ if user.get("role") != "buyer": continue
+ user.update(customer_orm.to_dict())
+ break
+
+ detail_list.append(order_item)
+
+ if detail_list:
+ msg.detail = detail_list
+ update_list.append(msg)
+
+ if update_list:
+ await self.model.bulk_update(update_list, fields=['detail'])
+
+
+ async def new_order(self, order_id: str, customer_orm: WeixinCustomer, is_refund: bool = False):
+
+ msg_set = set()
+ msg_list = []
+ now = datetime.now()
+
+ order_list = get_order_relative_user(lintao_client, order_id)
+ order_unique_list = {}
+ async for order in order_list:
+ order_state_string = order.get('order_state_string')
+ trade_no = order.get('trade_no')
+ if not is_refund and order_state_string not in ['待领单', '待抢单']:
+ logger.warning(f'订单{trade_no} 状态为{order_state_string},跳过')
+ continue
+
+ remark = order.get('remark')
+ if not is_refund and remark and '企微联系' in remark:
+ logger.warning(f'订单{trade_no} 状态为{order_state_string}, 备注:{remark}, 已备注企微联系,跳过')
+ continue
+
+ create_time = order.get('create_time')
+ if create_time and not is_refund:
+ create_time = datetime.fromisoformat(create_time.replace("Z", "+00:00"))
+ if (now - create_time).total_seconds() > 3600:
+ logger.warning(f'订单{order_id} 创建时间({create_time})与当前时间({now})相差超过60秒,跳过')
+ continue
+
+ if trade_no not in order_unique_list:
+ order_unique_list[trade_no] = order
+ logger.info(f'订单{order_id} 订单号{trade_no} 创建时间{create_time} 订单状态:{order_state_string} {order.get("title")}')
+
+ # 填充买家信息
+ for user in order.get("users", []):
+ if user.get("role") != "buyer": continue
+ user.update(customer_orm.to_dict())
+ break
+
+
+ if not order_unique_list:
+ logger.warning(f'订单{order_id} 没有符合条件的订单,跳过')
+ return
+
+ chat_info = await get_external_user_chat_info(customer_orm.weixin_id)
+ follow_user_list = chat_info.get('follow_user', [])
+
+ order_list = list(order_unique_list.values())
+ order_info = [
+ f"在({order.get('shop_name')})下单{order.get('price')}元,单号({order.get('trade_no')})"
+ for order in order_list
+ ]
+ for follow_user in follow_user_list:
+ follow_userid = follow_user.get('userid')
+ weixin_user_orm: WeixinUser = await WeixinUser.filter(userid=follow_userid).first()
+ owner_name = '未知'
+ if weixin_user_orm: owner_name = weixin_user_orm.english_name or weixin_user_orm.username
+
+ qiwei_customer_name = customer_orm.taobao_name or customer_orm.weixin_name
+
+ content = f"{'退款' if is_refund else ''}订单({qiwei_customer_name}) -({owner_name})- 刚{', '.join(order_info)}"
+ logger.info(f'新订单通知:{content}')
+ msg_hash_id = hashlib.md5(content.encode('utf-8')).hexdigest()
+ if msg_hash_id in msg_set: continue
+ msg_set.add(msg_hash_id)
+
+ msg = self.model(
+ hash_id=msg_hash_id,
+ title=qiwei_customer_name,
+ content=content,
+ detail=order_list,
+ type=MsgType.NEW_ORDER,
+ owner_id=follow_userid,
+ owner_name=owner_name
+ )
+ msg_list.append(msg)
+
+ if msg_list:
+ try: await self.model.bulk_create(msg_list)
+ except IntegrityError as e:
+ if "Duplicate entry" in str(e) and "hash_id" in str(e):
+ logger.info("检测到重复消息,已跳过")
+ # 忽略或处理重复
+ else:
+ # 其他完整性错误(如外键失败),应重新抛出
+ raise
+
+ async def get_order_user_days_before(self, days: int):
+ now = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
+ start_date, otherMemo = (now - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S"), "企微"
+
+ # 琪琪——松屿,小小丽——麦子,小小北——大喜,小泉——苏苏,小洋——东东,蓝莓——小满
+ index = 0
+ update_list, add_list = [], []
+ order_iter_list = [
+ (get_order_relative_user(lintao_client, start_date=start_date, otherMemo=otherMemo), ''),
+ (get_order_relative_user(lintao_client, start_date=start_date, customer='琪琪'), '松屿'),
+ (get_order_relative_user(lintao_client, start_date=start_date, customer='小小丽'), '麦子'),
+ (get_order_relative_user(lintao_client, start_date=start_date, customer='小小北'), '大喜'),
+ (get_order_relative_user(lintao_client, start_date=start_date, customer='小泉'), '苏苏'),
+ (get_order_relative_user(lintao_client, start_date=start_date, customer='小洋'), '东东'),
+ (get_order_relative_user(lintao_client, start_date=start_date, customer='蓝莓'), '小满'),
+ ]
+ for order_iter, staff_name in order_iter_list:
+ async for order_list in async_split_generator(order_iter, 10):
+ order_dict = {}
+ for order in order_list:
+ designer_list = list(filter(lambda x: x.get("role") == "desiger", order.get("users", [])))
+ designer = designer_list[0] if designer_list else None
+ if not designer: continue
+
+ name = designer.get("name", "")
+ if not name: continue
+
+ key = tuple([order.get("trade_no", ""), name])
+ if key in order_dict: continue
+ order_dict[key] = order
+ index += 1
+
+ _update_list, _add_list = await self.process_order_dict(order_dict, staff_name=staff_name)
+
+ if _update_list and _add_list:
+ logger.info(f'第{index}个订单 处理 {len(_update_list)} 条更新记录, {len(_add_list)} 条新增记录')
+ update_list.extend(_update_list)
+ add_list.extend(_add_list)
+
+ print(start_date)
+ _update_list, _add_list = [], []
+
+ async with _sync_to_feishu_lock:
+ task_id = gen_random_str()
+ _update_list, _add_list = await self.sync_to_feishu(task_id)
+
+ return {
+ "update_count": len(update_list),
+ "add_count": len(add_list),
+ "add_count_to_feishu": len(_add_list),
+ "update_count_to_feishu": len(_update_list),
+ "start_date": start_date,
+ "remark": otherMemo
+ }
+
+ async def process_order_dict(self, order_dict: dict, staff_name: str = ''):
+ async def to_follow_orm(order_info: dict):
+ follow_orm = Follow(**FollowFormErp(**order_info).model_dump())
+ # print(follow_orm.order_id, order_info.get("users", []))
+
+ for user in order_info.get("users", []):
+ if user.get("role") == "desiger":
+ follow_orm.designer_id = user.get("id", "")
+ follow_orm.designer_name = user.get("name", "")
+ elif user.get("role") == "taobao_kefu":
+ follow_orm.kefu_id = user.get("id", "")
+ follow_orm.kefu_name = user.get("name", "")
+ elif user.get("role") == "buyer":
+ follow_orm.customer_taobao_id = user.get("id", "")
+ follow_orm.customer_name = user.get("name", "")
+
+ CustomerUser = await WeixinCustomer.filter(taobao_id=follow_orm.customer_taobao_id).first()
+ if CustomerUser:
+ follow_orm.customer_id = CustomerUser.id
+
+ # 企微员工
+ if staff_name:
+ follow_orm.staff_name = staff_name
+ staff_orm = await WeixinUser.filter(english_name=f"{follow_orm.staff_name}-印刷定制").first()
+ if staff_orm:
+ follow_orm.staff_id = staff_orm.userid
+ else:
+ remark = follow_orm.remark
+ if remark:
+ staff_name_match = re.search(r"企微联系(.*?)拉群", remark)
+ if staff_name_match:
+ follow_orm.staff_name = staff_name_match.group(1).strip()
+ if follow_orm.staff_name:
+ staff_orm = await WeixinUser.filter(english_name=f"{follow_orm.staff_name}-印刷定制").first()
+ if staff_orm:
+ follow_orm.staff_id = staff_orm.userid
+
+ if not staff_name_match:
+ follow_orm.staff_name = remark
+
+ return follow_orm
+
+ update_list = []
+ follow_list = await Follow.filter(order_id__in=[key[0] for key in order_dict.keys()])
+ keys, update_keys = Follow.get_all_keys(exclude_fields=['id', 'updated_at', 'created_at', 'feishu_record_id', 'is_update_to_feishu', 'is_add_to_feishu']), set()
+ for follow in follow_list:
+ key = tuple([follow.order_id, follow.designer_name])
+ if key not in order_dict: continue
+
+ order_info = order_dict.pop(key, None)
+ follow_orm = await to_follow_orm(order_info)
+ # 比较是否一样,不一样的话,更新
+ updated_dict = {}
+ for key in keys:
+ origin_value = str(getattr(follow, key))
+ value = str(getattr(follow_orm, key))
+ if origin_value != value:
+ update_keys.add(key)
+ setattr(follow, key, value)
+ updated_dict[key] = f'{origin_value} -> {value}'
+
+ if updated_dict:
+ follow_orm.is_update_to_feishu = False
+ update_keys.add('is_update_to_feishu')
+ update_list.append(follow)
+ logger.info(f'订单 {follow.order_id} 设计员 {follow.designer_name} 更新字段 {updated_dict}')
+
+ if update_list:
+ await Follow.bulk_update(update_list, fields=update_keys)
+
+ add_list = []
+ for key, order_info in order_dict.items():
+ follow_orm = await to_follow_orm(order_info)
+ add_list.append(follow_orm)
+
+ if add_list:
+ await Follow.bulk_create(add_list)
+
+ return update_list, add_list
+
+ async def sync_to_feishu(self, task_id: str):
+
+ add_list = []
+ # 需要追加的记录
+ follow_list = await Follow.filter(is_update_to_feishu=False, feishu_record_id__isnull=True)
+ if follow_list:
+ for item_list in split_generator(follow_list, 100):
+ result = await create_feishu_records(
+ feishu_client,
+ app_token="HPiNbbW3YaYhBjsjLQychK9gnbf",
+ table_id="tblQGVfmglJgZuBf",
+ records=[{"fields": item.to_feishu_record(fields={"同步任务号": task_id})["fields"]} for item in item_list],
+ )
+
+ _update_list = []
+ result_list = result.get("data", {}).get("records", [])
+ for record, item in zip(result_list, item_list):
+ item.feishu_record_id = record["record_id"]
+ item.is_add_to_feishu = True
+ item.is_update_to_feishu = True
+ _update_list.append(item)
+
+ if _update_list:
+ await Follow.bulk_update(_update_list, fields=['feishu_record_id', 'is_add_to_feishu', 'is_update_to_feishu'])
+
+ logger.info(f'追加 {len(_update_list)} 条记录到飞书')
+ add_list.extend(_update_list)
+
+ update_list = []
+ # 需要更新的记录
+ # follow_list = await Follow.filter(is_update_to_feishu=False, feishu_record_id__isnull=False)
+ # if follow_list:
+ # for item_list in split_generator(follow_list, 100):
+ # await create_feishu_records(
+ # get_feishu_client,
+ # app_token="JTqnbdjb7aEMm3srrLfcidYcnxc",
+ # table_id="tblXq8AmG22uK5n1",
+ # records=item_list,
+ # )
+
+ return update_list, add_list
+
+ async def set_read(self, id: str):
+ await Msg.filter(id=id).update(is_read=True, read_at=datetime.now())
+
+msg_controller = MsgController()
+
diff --git a/app/controllers/role.py b/app/controllers/role.py
new file mode 100644
index 0000000..cb6a528
--- /dev/null
+++ b/app/controllers/role.py
@@ -0,0 +1,27 @@
+from typing import List
+
+from app.core.crud import CRUDBase
+from app.models.admin import Api, Menu, Role
+from app.schemas.roles import RoleCreate, RoleUpdate
+
+
+class RoleController(CRUDBase[Role, RoleCreate, RoleUpdate]):
+ def __init__(self):
+ super().__init__(model=Role)
+
+ async def is_exist(self, name: str) -> bool:
+ return await self.model.filter(name=name).exists()
+
+ async def update_roles(self, role: Role, menu_ids: List[int], api_infos: List[dict]) -> None:
+ await role.menus.clear()
+ for menu_id in menu_ids:
+ menu_obj = await Menu.filter(id=menu_id).first()
+ await role.menus.add(menu_obj)
+
+ await role.apis.clear()
+ for item in api_infos:
+ api_obj = await Api.filter(path=item.get("path"), method=item.get("method")).first()
+ await role.apis.add(api_obj)
+
+
+role_controller = RoleController()
diff --git a/app/controllers/table.py b/app/controllers/table.py
new file mode 100644
index 0000000..a86634b
--- /dev/null
+++ b/app/controllers/table.py
@@ -0,0 +1,62 @@
+from typing import List
+
+import json
+from app.core.crud import CRUDBase
+from app.models.admin import Api, Menu, Codegen
+from app.schemas.codegen import CodegenCreate, CodegenUpdate
+from app.utils.db import DatabaseInfo
+from app.utils.codegen import CodeGenerator
+
+class CodegenController(CRUDBase[Codegen, CodegenCreate, CodegenUpdate]):
+ def __init__(self):
+ super().__init__(model=Codegen)
+
+ async def is_exist(self, name: str) -> bool:
+ return await self.model.filter(name=name).exists()
+
+ async def import_table(self, connect: str='', importTables: List[str] = None) -> Codegen:
+
+ default_frontend_config = {
+ "editable": True,
+ "listable": True,
+ "sortable": True,
+ "filterable": False,
+ "filter_operator": '=', # = | >= 默认过滤操作符为包含
+ "display_type": 'text', # 默认显示类型为文本
+ }
+
+ cnt = 0
+
+ with DatabaseInfo(
+ host="lt.330770.xyz",
+ port=3307,
+ user="root",
+ password="rap_sky",
+ database="rpa"
+ ) as db:
+
+ for table_name in importTables:
+ table = db.get_table_structure(table_name)
+ # result.append(fields)
+ fields = table.get('fields')
+ for field in fields:
+ # if field.name in ['id']:
+ if 'editable' not in field:
+ field['editable'] = field['required']
+ new_data = {**default_frontend_config, **field}
+ field.update(**new_data)
+ description = table.get('tableComment')
+ await self.create(CodegenCreate(name=table_name, description=description , fields=json.dumps(fields,ensure_ascii=False)))
+ cnt += 1
+
+ return cnt
+
+ async def preview(self, table_id):
+ generator = CodeGenerator()
+ config_orm = await self.get(id=table_id)
+ entity_config = await config_orm.to_dict()
+ generated_files = generator.generate_files(entity_config, 'relation-demo', {})
+ return generated_files
+
+
+codegen_controller = CodegenController()
diff --git a/app/controllers/user.py b/app/controllers/user.py
new file mode 100644
index 0000000..9059a61
--- /dev/null
+++ b/app/controllers/user.py
@@ -0,0 +1,60 @@
+from datetime import datetime
+from typing import List, Optional
+
+from fastapi.exceptions import HTTPException
+
+from app.core.crud import CRUDBase
+from app.models.admin import User
+from app.schemas.login import CredentialsSchema
+from app.schemas.users import UserCreate, UserUpdate
+from app.utils.password import get_password_hash, verify_password
+
+from .role import role_controller
+
+
+class UserController(CRUDBase[User, UserCreate, UserUpdate]):
+ def __init__(self):
+ super().__init__(model=User)
+
+ async def get_by_email(self, email: str) -> Optional[User]:
+ return await self.model.filter(email=email).first()
+
+ async def get_by_username(self, username: str) -> Optional[User]:
+ return await self.model.filter(username=username).first()
+
+ async def create_user(self, obj_in: UserCreate) -> User:
+ obj_in.password = get_password_hash(password=obj_in.password)
+ obj = await self.create(obj_in)
+ return obj
+
+ async def update_last_login(self, id: int) -> None:
+ user = await self.model.get(id=id)
+ user.last_login = datetime.now()
+ await user.save()
+
+ async def authenticate(self, credentials: CredentialsSchema) -> Optional["User"]:
+ user = await self.model.filter(username=credentials.username).first()
+ if not user:
+ raise HTTPException(status_code=400, detail="无效的用户名")
+ verified = verify_password(credentials.password, user.password)
+ if not verified:
+ raise HTTPException(status_code=400, detail="密码错误!")
+ if not user.is_active:
+ raise HTTPException(status_code=400, detail="用户已被禁用")
+ return user
+
+ async def update_roles(self, user: User, role_ids: List[int]) -> None:
+ await user.roles.clear()
+ for role_id in role_ids:
+ role_obj = await role_controller.get(id=role_id)
+ await user.roles.add(role_obj)
+
+ async def reset_password(self, user_id: int):
+ user_obj = await self.get(id=user_id)
+ if user_obj.is_superuser:
+ raise HTTPException(status_code=403, detail="不允许重置超级管理员密码")
+ user_obj.password = get_password_hash(password="123456")
+ await user_obj.save()
+
+
+user_controller = UserController()
diff --git a/app/controllers/weixin/customer.py b/app/controllers/weixin/customer.py
new file mode 100644
index 0000000..6a25a58
--- /dev/null
+++ b/app/controllers/weixin/customer.py
@@ -0,0 +1,607 @@
+
+from app.core.crud import CRUDBase
+from app.schemas.weixin import (
+ WeixinCustomerCreate,
+ WeixinCustomerUpdate,
+ WeixinCustomerXingyunCreate,
+)
+from typing import List
+
+from app.models.weixin import WeixinCustomer, WeixinUser, CustomerGroup
+from app.schemas.weixin import WeixinUserBindInfo, WeixinOrderBindInfo
+
+from some_sdk.services.binder import lintao_client, xy_client, wk_client
+from some_sdk.lintao_sdk.biz.by_order import get_order_relative_user, save_other_memo
+from some_sdk.xingyun_sdk.apis.customer import bind_user_to_xingyun, list_user_order
+from some_sdk.xingyun_sdk.apis.work_external_contact import list_all_contact_by_addtime, list_all_contact_by_addtime_and_tag
+from some_sdk.wk_weixin_sdk.apis.corp_user import from_service_external_userid
+from some_sdk.wk_weixin_sdk.apis.extern_user import get_external_user_chat_info
+
+from app.controllers.msg import msg_controller
+from app.schemas.msg import MsgType
+
+from app.utils.common import async_split_generator
+import pickle
+import hashlib
+
+import re, os
+from datetime import datetime, timedelta
+from app.core.cache import cache_if
+
+import logging
+logger = logging.getLogger(__name__)
+
+EXCLUDE_SHOP_NAMES = ['领淘文具旗舰店']
+XINGYUN_USE_TIME = datetime(2025, 6, 18)
+
+class WeixinCustomerController(CRUDBase[WeixinCustomer, WeixinCustomerCreate, WeixinCustomerUpdate]):
+ def __init__(self):
+ super().__init__(model=WeixinCustomer)
+
+ async def update_from_group(self, external_members: list[dict]):
+ # 同步更新数据库
+ member_dict = {}
+ for member in external_members:
+ member = member.copy()
+ userid = member.pop('userid', None)
+
+ weixin_name = member.pop('name', None)
+ weixin_unionid = member.pop('unionid', None)
+
+ member_info = WeixinCustomer.orm_format(member)
+ member_info['weixin_id'] = userid
+ member_info['weixin_name'] = weixin_name
+ member_info['weixin_unionid'] = weixin_unionid
+ member_dict[userid] = member_info
+
+ member_list = await self.model.filter(weixin_id__in=member_dict.keys())
+
+ update_list = []
+ update_fileds = ['weixin_unionid', 'weixin_id', 'weixin_name', 'order_id', 'taobao_id', 'taobao_name', 'extra', 'need_confirm']
+
+ history = {}
+ for member in member_list:
+ member_info = member_dict.pop(member.weixin_id, history.get(member.weixin_id, {}))
+ if not member_info: continue
+ history[member.weixin_id] = member_info
+
+ member_info['extra'] = {**(member.extra or {}), **member_info['extra']}
+
+ updated = {}
+ for field in update_fileds:
+ old_value = getattr(member, field)
+ if field not in member_info:
+ continue
+ value = member_info.get(field)
+ if old_value != value:
+ setattr(member, field, value)
+ updated[f'{field}:{str(old_value)}'] = value
+ if updated:
+ logger.info(f'用户 {member.weixin_name} 更新关系为 {updated}')
+ update_list.append(member)
+ if update_list:
+ await self.model.bulk_update(update_list, fields=update_fileds)
+
+ # 新增用户
+ create_list = []
+ for userid, member_info in member_dict.items():
+ create_list.append(WeixinCustomer(
+ **member_info,
+ ))
+ if create_list:
+ await self.model.bulk_create(create_list)
+
+ return {
+ 'update_count': len(update_list),
+ 'create_count': len(create_list),
+ }
+
+ async def bind_user(self, user_in: WeixinUserBindInfo):
+ if not user_in.taobao_id:
+ raise ValueError("淘宝ID不能为空")
+ await self.model.filter(taobao_id=user_in.taobao_id).update_from_dict(user_in.model_dump(exclude_unset=True))
+
+ async def remark_order(self, order: dict, remark: str):
+ ctid = order.get("ctid") or order.get("trade_no")
+ # if remark in (order.get("remark") or ''):
+ if '企微' in (order.get("remark") or ''):
+ logger.info(f'订单 {ctid} 备注中已包含 企微 备注,无需重复添加')
+ return
+ remark_list = [order.get("remark", ""), remark]
+ append_remark = ';'.join(filter(lambda x: x, remark_list))
+
+ if os.getenv("APP_ENV") == "prod":
+ result = await save_other_memo(lintao_client, order_id=ctid, other_memo=append_remark)
+ assert result["type"] == "success", f"保存备注失败,响应: {result}"
+ logger.info(f'成功为订单 {ctid} 打上备注 {append_remark}')
+ else:
+ logger.info(f'非生产环境,不实际为订单 {ctid} 打上备注 {append_remark}')
+
+ async def remark_order_by_order_id(self, remark: str, order_id: str=None, orders: list=None):
+ if not orders:
+ assert order_id, "订单ID不能为空"
+ orders = [o async for o in get_order_relative_user(lintao_client, trade_no=order_id)]
+ assert orders, f"订单 {order_id} 不存在"
+
+ # 打上备注
+ logger.info(f'即将为以下订单 {[o.get("trade_no") for o in orders]} 打上备注 {remark}')
+ need_monitor_order_list = []
+ for order in orders:
+ if not order.get('title'):
+ # 未领单的订单,需要添加到监控列表中进行监控,防止被刷掉
+ need_monitor_order_list.append(order)
+ logger.info(f'订单 {order.get("trade_no")} 未领单,需要添加到监控列表中进行监控,防止被刷掉')
+
+ try:
+ await self.remark_order(order, remark=remark)
+ except Exception as e:
+ logger.error(f'为订单 {order.get("trade_no")} 打上备注 {remark} 失败,异常: {e}')
+ need_monitor_order_list.append(order)
+
+ return need_monitor_order_list
+
+
+ async def bind_order(self, bind_in: WeixinOrderBindInfo):
+ orders = [o async for o in get_order_relative_user(lintao_client, trade_no=bind_in.order_id)]
+ assert orders, f"订单 {bind_in.order_id} 不存在"
+
+ orders = [o for o in orders if o.get('trade_no') == bind_in.order_id]
+ order = orders[0]
+
+ buyer_ids = []
+ for user in order.get('users', []):
+ taobao_id = user.get('id', '')
+ if not taobao_id or re.match(r'^\d+$', taobao_id): continue
+ buyer_ids.append(taobao_id)
+
+ # 更新还是新建?
+ orms = await self.model.filter(weixin_id=bind_in.userid).all()
+ logger.info(f'用户 {bind_in.userid} 准备绑定订单 {len(orms)} {[orm.weixin_name for orm in orms]}')
+
+ update_list = []
+ for orm in orms:
+ if orm.order_id and orm.order_id != order.get('trade_no'):
+ logger.warning(f'用户 {orm.weixin_name} 已绑定订单 {orm.order_id},即将覆盖为 {order.get("trade_no")}')
+
+ orm.weixin_id = bind_in.userid
+ orm.order_id = order.get('trade_no')
+ orm.shop_name = order.get('shop_name', '')
+ orm.taobao_id = user.get('id', '')
+ orm.taobao_name = user.get('name', '')
+ update_list.append(orm)
+
+ if update_list:
+ logger.info(f'更新用户 {bind_in.userid} 绑定订单 {order.get("trade_no")} 中的用户 {user}')
+ await self.model.bulk_update(update_list, fields=['order_id', 'weixin_id', 'taobao_id', 'taobao_name'])
+ return True, orders
+
+ if not orms:
+ logger.info(f'绑定用户 {bind_in.userid} 绑定订单 {order.get("trade_no")} 中的用户 {user}')
+ orm_user = WeixinCustomer(
+ weixin_id=bind_in.userid,
+ order_id=order.get('trade_no'),
+ taobao_id=user.get('id', ''),
+ taobao_name=user.get('name', ''),
+ )
+ await orm_user.save()
+ return True, orders
+
+ return False, orders
+
+ async def bind_xingyun_user(self, contact_orm: WeixinCustomer):
+ assert contact_orm.xingyun_id, "星云用户ID不能为空"
+ if contact_orm.shop_name in EXCLUDE_SHOP_NAMES:
+ contact_orm.xingyun_sync = True
+ contact_orm.extra = contact_orm.extra or {}
+ contact_orm.extra['system_remark'] = f'店铺名称: {contact_orm.shop_name},没有开通星云有客服务,故此跳过。'
+ return
+
+ order_id = contact_orm.order_id.split('_')[-1]
+ logger.info(f'同步客户订单信息到星云: {contact_orm.xingyun_id} -》 {order_id}')
+ try:
+ await bind_user_to_xingyun(xy_client, contact_orm.xingyun_id, order_id)
+ except:
+ logger.error(f'同步客户订单信息到星云失败: {contact_orm.xingyun_id} -》 {order_id}, 尝试寻找同用户的其他订单来绑定')
+ shop_name = ''
+ async for order in get_order_relative_user(lintao_client, buyer_nick=contact_orm.taobao_name):
+ shop_name = order.get('shop_name', '')
+ if shop_name in EXCLUDE_SHOP_NAMES:
+ continue
+
+ if not order.get('create_time'): continue
+ create_time = order.get('create_time')
+ create_time = datetime.fromisoformat(create_time.replace("Z", "+00:00"))
+ if create_time < XINGYUN_USE_TIME:
+ continue
+
+ order_id = order.get('trade_no').split('_')[-1]
+ await bind_user_to_xingyun(xy_client, contact_orm.xingyun_id, order_id)
+ logger.info(f'通过同用户的其他订单来同步客户订单信息到星云成功: {contact_orm.xingyun_id} -》 {order_id}')
+ break
+
+ if shop_name and not contact_orm.shop_name:
+ contact_orm.shop_name = order.get('shop_name', '')
+ await contact_orm.save()
+
+ contact_orm.xingyun_sync = True
+
+ async def load_all_user_from_xingyun(self, task_id: str, add_time_start: datetime, add_time_end: datetime):
+ logger.info(f'从星云加载客户数据,时间范围 {task_id} {add_time_start} - {add_time_end}')
+ contact_iter = list_all_contact_by_addtime_and_tag(xy_client, task_id=task_id, keyword='', add_time_start=add_time_start, add_time_end=add_time_end)
+
+ return await self._load_user_from_xingyun(contact_iter, eager_load=True)
+
+ async def load_user_from_xingyun(self, add_time_start: datetime, add_time_end: datetime):
+ logger.info(f'从星云加载客户数据,时间范围 {add_time_start} - {add_time_end}')
+ contact_iter = list_all_contact_by_addtime(xy_client, keyword='', add_time_start=add_time_start, add_time_end=add_time_end)
+
+ return await self._load_user_from_xingyun(contact_iter)
+
+ async def _load_user_from_xingyun(self, contact_iter: list, eager_load: bool = False):
+ logger.info(f'从星云加载客户数据,开始同步')
+ load_result = {"update_count": 0, "add_count": 0}
+ async for contact_list in async_split_generator(contact_iter, 10):
+ try: result = await self.save_xingyun_contact_info(contact_list)
+ except Exception as e:
+ logger.error(f'从星云加载客户数据,同步失败 {e}')
+ logger.exception(e)
+ raise e
+
+ # 全部都已经同步完了
+ if not eager_load and result.get("update_count", 0) + result.get("add_count", 0) == 0:
+ logger.info(f'从星云加载客户数据,全部同步完成')
+ break
+
+ load_result['update_count'] += result.get("update_count", 0)
+ load_result['add_count'] += result.get("add_count", 0)
+ logger.info(f'从星云加载客户数据{eager_load},更新 {result.get("update_count", 0)} 条,新增 {result.get("add_count", 0)} 条')
+ logger.info(f'从星云加载客户数据,总 {load_result["update_count"] + load_result["add_count"]} 条,更新 {load_result["update_count"]} 条,新增 {load_result["add_count"]} 条')
+ await self.sync_xingyun_contact_info()
+
+ return load_result
+
+ async def save_xingyun_contact_info(self, contact_list: list):
+ contact_dict = {contact.get('cid'): contact for contact in contact_list}
+ contact_orm_list: List[WeixinCustomer] = await self.model.filter(xingyun_id__in=contact_dict.keys())
+
+ field_names = set()
+ update_list = []
+
+ async def update(contact_dict, contact_orm_list, key):
+ history = {}
+ for contact_orm in contact_orm_list:
+ value = getattr(contact_orm, key)
+ contact = contact_dict.pop(value, history.get(value, {}))
+ if not contact: continue
+ history[value] = contact
+
+ try:
+ contact_in = WeixinCustomerXingyunCreate(**contact).transform_to_extra()
+ except Exception as e:
+ logger.error(f'客户数据转换错误,chat_id: {contact}, {e}')
+ continue
+ # print(type(contact_in), contact_in)
+ updated = False
+
+ contact_in_dict = contact_in.model_dump(exclude_unset=True)
+ field_names.update(contact_in_dict.keys())
+
+ for field_name in field_names:
+
+ old_value = getattr(contact_orm, field_name)
+ value = contact_in_dict.get(field_name)
+ if old_value != value:
+ setattr(contact_orm, field_name, value)
+ updated = True
+
+ new_extra = {**(contact_orm.extra or {}), **contact_in.extra}
+ if contact_in.extra != new_extra:
+ if new_extra != contact_orm.extra:
+ contact_orm.extra = new_extra
+ updated = True
+
+ # 触发同步信息到星云
+ if updated and not contact_orm.xingyun_sync and contact_orm.xingyun_id:
+ try:
+ await self.bind_xingyun_user(contact_orm)
+ update_list.append(contact_orm)
+ except Exception as e:
+ logger.error(f'客户数据同步错误,xingyun_id: {contact_orm.xingyun_id}, {e}')
+ continue
+
+ if update_list:
+ await self.model.bulk_update(update_list, list(field_names) + ['extra', 'xingyun_sync'])
+
+ await update(contact_dict, contact_orm_list, 'xingyun_id')
+
+ add_dict = {}
+ for contact in contact_dict.values():
+ externalUserid = contact.get('externalUserid', None)
+ if not externalUserid:
+ continue
+ try:
+ resp = await from_service_external_userid(wk_client, externalUserid)
+ except Exception as e:
+ logger.error(f'从星云加载客户数据,获取用户ID失败,externalUserid: {externalUserid}, {e}')
+ continue
+ if not resp or not resp.get('external_userid'):
+ continue
+
+ external_userid = resp.get('external_userid')
+ contact['external_userid'] = external_userid
+ add_dict[external_userid] = contact
+
+ contact_orm_list = await self.model.filter(weixin_id__in=add_dict.keys())
+ await update(add_dict, contact_orm_list, 'weixin_id')
+
+ add_list = []
+ for contact in add_dict.values():
+ contact_in = WeixinCustomerXingyunCreate(**contact).transform_to_extra()
+ add_list.append(self.model(**contact_in.model_dump(exclude_unset=True)))
+
+ if add_list:
+ await self.model.bulk_create(add_list)
+
+ return {
+ 'update_count': len(update_list),
+ 'add_count': len(add_list),
+ }
+
+ async def bind_user(self, bind_info: WeixinUserBindInfo):
+ return await self.bind_user(bind_info)
+
+ async def monitor_erp_order(self):
+ # 从领淘ERP加载订单相关用户
+ # 获取上一个订单的创建时间
+ cache_file = 'env/order_cache.pkl'
+ newest_order_create_time = None
+ today_end = datetime.now().replace(hour=23, minute=59, second=59, microsecond=0)
+ try:
+ with open(cache_file, 'rb') as f:
+ last_order = pickle.load(f)
+ newest_order_create_time = last_order.get('create_time')
+ logger.info(f'从缓存文件加载最新订单创建时间,{newest_order_create_time}')
+ except FileNotFoundError:
+ newest_order_create_time = today_end.replace(hour=0, minute=0, second=0, microsecond=0)
+ newest_order_create_time = newest_order_create_time.strftime('%Y-%m-%d %H:%M:%S')
+
+ order_list = [o async for o in get_order_relative_user(lintao_client, start_date=newest_order_create_time, end_date=today_end.strftime('%Y-%m-%d %H:%M:%S'))]
+ msg_list, newest_order = await self.find_bind_order(order_list)
+
+ # 保存最新订单到缓存文件
+ if newest_order:
+ newest_order['create_time'] = newest_order['create_time'].replace('T', ' ')
+ with open(cache_file, 'wb') as f:
+ pickle.dump(newest_order, f)
+
+ return msg_list
+
+ async def find_bind_order(self, order_list: list):
+
+ buyer_order_dict = {}
+ newest_order = None
+ last_erp_id = 0
+
+ order_id_set = set()
+ for order in order_list:
+ erp_id = order.get('id')
+ if erp_id <= 6368700: break
+
+ create_time = order.get('create_time')
+ if last_erp_id < erp_id:
+ if create_time: newest_order = order
+ last_erp_id = erp_id
+
+ order_id = order.get('trade_no')
+ if order_id in order_id_set: continue
+ order_id_set.add(order_id)
+
+ users = order.get("users", [])
+ buyer_id_set = set()
+ for user in users:
+ role = user.get('role')
+ if role != 'buyer': continue
+ buyer_id = user.get('id')
+ if not buyer_id: continue
+ if buyer_id in buyer_id_set: continue
+ buyer_id_set.add(buyer_id)
+
+ buyer_order_dict.setdefault(buyer_id, []).append(order)
+
+ customer_list = await self.model.filter(taobao_id__in=buyer_order_dict.keys()).all()
+
+ # 通知新订单
+ # (客户id)-(所属企微客服)刚在(店铺名)下单-(金额)
+ msg_set = set()
+ msg_list = []
+ for customer_orm in customer_list:
+ order_list = buyer_order_dict.get(customer_orm.taobao_id, [])
+
+ order_info = [
+ f"在({order.get('shop_name')})下单{order.get('price')}元,单号({order.get('trade_no')})"
+ for order in order_list
+ ]
+ chat_info = await get_external_user_chat_info(customer_orm.weixin_id)
+ follow_user_list = chat_info.get('follow_user', [])
+
+ for follow_user in follow_user_list:
+ follow_userid = follow_user.get('userid')
+ weixin_user_orm: WeixinUser = await WeixinUser.filter(userid=follow_userid).first()
+ owner_name = '未知'
+ if weixin_user_orm: owner_name = weixin_user_orm.english_name or weixin_user_orm.username
+ qiwei_customer_name = customer_orm.taobao_name or customer_orm.weixin_name
+
+ content = f"({qiwei_customer_name}) -({owner_name})- 刚{', '.join(order_info)}"
+ msg_hash_id = hashlib.md5(content.encode('utf-8')).hexdigest()
+ if msg_hash_id in msg_set: continue
+ msg_set.add(msg_hash_id)
+
+ # 检查消息是否已存在
+ msg_orm = await msg_controller.model.filter(hash_id=msg_hash_id).first()
+ if msg_orm: continue
+
+ msg = msg_controller.model(
+ hash_id=msg_hash_id,
+ title=qiwei_customer_name,
+ content=content,
+ detail=order_list,
+ type=MsgType.NEW_ORDER,
+ owner_id=follow_userid,
+ owner_name=owner_name
+ )
+ logger.info(f'创建新订单通知消息,{msg.content}')
+ msg_list.append(msg)
+
+ if msg_list:
+ await msg_controller.model.bulk_create(msg_list)
+
+ return msg_list, newest_order
+
+ async def get_customer_by_weixin_id(self, weixin_id: str):
+ async with cache_if(f'customer:user_datail_0:{weixin_id}', ttl=3600*24) as cache:
+ if cache.hit:
+ result = cache.value
+ return result
+ else:
+ result = await self._get_customer_by_weixin_id(weixin_id)
+ cache.set(result)
+ return result
+
+ async def _get_customer_by_weixin_id(self, weixin_id: str):
+ chat_info = await get_external_user_chat_info(weixin_id)
+ follow_user_list = chat_info.get('follow_user', [])
+ external_contact = chat_info.get('external_contact', {})
+
+ if external_contact:
+ customer_orm = await self.model.filter(weixin_id=weixin_id).first()
+ if customer_orm:
+ customer_orm.weixin_avatar = external_contact.get('avatar')
+ customer_orm.weixin_unionid = external_contact.get('unionid')
+ customer_orm.weixin_name = external_contact.get('name')
+ await customer_orm.save()
+
+ user_ids = [follow_user.get('userid') for follow_user in follow_user_list]
+ weixin_user_orm_list = await WeixinUser.filter(userid__in=user_ids).all()
+ weixin_user_orm_dict = {weixin_user_orm.userid: weixin_user_orm for weixin_user_orm in weixin_user_orm_list}
+ for follow_user in follow_user_list:
+ follow_userid = follow_user.get('userid')
+ weixin_user_orm = weixin_user_orm_dict.get(follow_userid)
+
+ if weixin_user_orm:
+ follow_user['username'] = weixin_user_orm.english_name or weixin_user_orm.username
+
+ return {
+ **external_contact,
+ 'follow_user_list': follow_user_list,
+ }
+
+ # for follow_user in follow_user_list:
+ # follow_userid = follow_user.get('userid')
+
+ async def get_user_detail(self, weixin_id: str):
+ async with cache_if(f'customer:user_datail:{weixin_id}', ttl=3600) as cache:
+ if cache.hit:
+ result = cache.value
+ return result
+ else:
+ customer = await weixin_customer_controller.model.get_or_none(weixin_id=weixin_id)
+ # if customer: return customer.to_dict()
+ print(f'get_user_detail, weixin_id: {weixin_id}, customer: {customer}')
+
+ # 通过中间表 CustomerGroup 查询其加入的所有群
+ memberships = await CustomerGroup.filter(
+ customer=customer
+ ).prefetch_related('group') # 预加载 group 信息
+
+ group_list = []
+ result = {'group_list': group_list}
+ for m in memberships:
+ data = await m.to_dict()
+ # data['group'] = await m.group.to_dict()
+ data['chat_name'] = m.group.name
+ data['create_time'] = m.group.create_time
+ group_list.append(data)
+
+ chat_info = await self.get_customer_by_weixin_id(weixin_id)
+ result['user_detail'] = chat_info
+ cache.set(result)
+
+ return result
+
+ # 同步星云和数据库中的客户信息: 同步绑定信息到星云、从星云中加载订单信息
+ async def sync_xingyun_contact_info(self, eager_load: bool = False):
+ # 从星云同步绑定信息到本地
+ await self._sync_xingyun_contact_info(eager_load=eager_load, order_id__isnull=False)
+ # 从本地同步订单信息到星云
+ await self._sync_xingyun_contact_info(eager_load=eager_load, order_id__isnull=True, limit=10)
+
+ async def _sync_xingyun_contact_info(self, eager_load: bool = False, max_try: int = 2, limit: int = 20, **query_kwargs):
+ query_kwargs = query_kwargs or {}
+
+ last_id = 0
+ while eager_load or max_try > 0:
+ max_try -= 1
+ if last_id:
+ contact_orm_list = await self.model.filter(xingyun_sync=False, xingyun_id__isnull=False, id__lt=last_id, **query_kwargs).order_by('-id').limit(limit).all()
+ else:
+ contact_orm_list = await self.model.filter(xingyun_sync=False, xingyun_id__isnull=False, **query_kwargs).order_by('-id').limit(limit).all()
+
+ if not contact_orm_list: break
+ logger.debug(f'同步客户数据,{len(contact_orm_list)}')
+
+ has_new = False
+ update_list = []
+ for contact_orm in contact_orm_list:
+ last_id = contact_orm.id
+ has_new = True
+
+ if contact_orm.need_confirm:
+ logger.info(f'客户需要确认绑定: {contact_orm}')
+ continue
+
+ if contact_orm.order_id:
+ try:
+ await self.bind_xingyun_user(contact_orm)
+ update_list.append(contact_orm)
+ except Exception as e:
+ logger.error(f'同步客户订单信息到星云错误,xingyun_id: {contact_orm.xingyun_id}, {e}')
+ continue
+ else:
+ # list_user_order
+ try:
+ order_list = list_user_order(xy_client, cid=contact_orm.xingyun_id)
+ except Exception as e:
+ logger.error(f'客户数据同步错误,xingyun_id: {contact_orm.xingyun_id}, {e}')
+ logger.exception(e)
+ continue
+
+ shopNameSet, order_id_list = set(), []
+ async for order in order_list:
+ shopName = order.get('shopName')
+ if shopName in shopNameSet:
+ continue
+ shopNameSet.add(shopName)
+ order_id_list.append(order.get('orderId'))
+ # 多店铺用的用的名称和ID都一样,所以不区分
+ break
+
+ for order_id in order_id_list:
+ try:
+ await self.bind_order(WeixinOrderBindInfo(
+ userid=contact_orm.weixin_id,
+ order_id=order_id,
+ ))
+ except AssertionError as e:
+ logger.error(f'同步客户订单信息到本地错误,xingyun_id: {contact_orm.xingyun_id}, {e}')
+ logger.exception(e)
+ continue
+ contact_orm.xingyun_sync = True
+ update_list.append(contact_orm)
+ logger.info(f'同步客户订单信息到本地: {contact_orm.xingyun_id} -》 {order_id}')
+
+ if update_list:
+ await self.model.bulk_update(update_list, ['xingyun_sync'])
+
+ if not has_new: break
+
+weixin_customer_controller = WeixinCustomerController()
diff --git a/app/controllers/weixin/group.py b/app/controllers/weixin/group.py
new file mode 100644
index 0000000..362c02a
--- /dev/null
+++ b/app/controllers/weixin/group.py
@@ -0,0 +1,408 @@
+from tortoise.expressions import Q
+
+from app.core.crud import CRUDBase
+from app.schemas.weixin import (
+ WeixinGroupChatCreate,
+ WeixinGroupChatUpdate,
+ WeixinGroupChatXingyunCreate,
+)
+
+from some_sdk.services.binder import wk_client, lintao_client, xy_client
+from some_sdk.wk_weixin_sdk.apis import corp_group
+from some_sdk.wk_weixin_sdk.apis.extern_user import get_external_user_chat_info
+from some_sdk.xingyun_sdk.apis.work_user import list_work_group
+from some_sdk.xingyun_sdk.apis.customer import list_user_join_group
+
+from some_sdk.lintao_sdk.biz.by_order import get_order_relative_user
+from some_sdk.lintao_sdk.apis.orderlist import list_product_raw, get_order_log
+
+from app.models.weixin import WeixinGroupChat, RoleType, CustomerGroup
+from app.controllers.weixin.user import weixin_user_controller
+from app.controllers.weixin.customer import weixin_customer_controller
+from app.utils.common import async_split_generator, async_generator_to_list
+from app.core.cache import cache_if, invalidate_cache
+
+import re
+
+import logging
+logger = logging.getLogger(__name__)
+
+ROLE_LIST = [role.value for role in RoleType]
+
+class WeixinGroupChatController(CRUDBase[WeixinGroupChat, WeixinGroupChatCreate, WeixinGroupChatUpdate]):
+ def __init__(self):
+ super().__init__(model=WeixinGroupChat)
+
+ async def get_external_group_chat_info(self, chat_id: str):
+ group_info = await corp_group.get_external_group_chat_info(wk_client, chat_id)
+
+ internal_member_list = group_info['internal_member_list']
+ external_member_list = group_info['external_member_list']
+ group_name = group_info.get('name')
+
+ external_member_list.sort(key=lambda user: user.get('join_time'))
+
+ unique_dict = {
+ 'name': group_name,
+ 'external_user_count': len(external_member_list),
+ 'internal_member_count': len(internal_member_list),
+ }
+ group_info.update(unique_dict)
+ # 同步更新数据库
+ status, obj = await self.create_or_update(group_info, query_kwargs={'chat_id': chat_id}, update_kwargs=unique_dict)
+ if not status:
+ logger.debug(f'群聊数据不变,不用更新,chat_id: {chat_id}')
+ # elif status == 'create':
+ # await self.create_customer_group(chat_id)
+ else:
+ # 群更新:触发数据同步事件
+ # event_manager.subscribe("group_chat_updated", update_cache_handler, EventPriority.MEDIUM)
+
+ # 只保存准确无误的数据
+ # buyer_nick = get_buyer_nick_from_group_name(group_name)
+ # logger.debug(f'group_name: {group_name}; buyer_nick: {buyer_nick}')
+ # if not buyer_nick:
+ # logger.error(f'群聊未命名,无法绑定订单,chat_id: {chat_id}')
+ # return group_info
+
+ # self.load_erp_order_info_by_group(buyer_nick, internal_member_list, external_member_list)
+
+ update_result1 = await weixin_user_controller.update_from_group([*internal_member_list])
+ update_result2 = await weixin_customer_controller.update_from_group([*external_member_list])
+ logger.debug(f'群聊用户数据更新结果,chat_id: {chat_id}, {update_result1}')
+ logger.debug(f'群聊客户数据更新结果,chat_id: {chat_id}, {update_result2}')
+ # 刷新用户缓存
+ for user in [*internal_member_list, *external_member_list]:
+ await self.expire_user_cache(user.get('userid'))
+
+ group_info['is_create'] = status == 'create'
+ group_info['detect_update'] = status != False
+ return group_info
+
+ # 从星云有客中加载群聊信息
+ async def load_xingyun_group_info(self, chat_name: str=''):
+ # 检索出最新的记录,只加载最新的记录
+ newest_group = await self.model.filter(xingyun_chat_id__isnull=False).order_by('-create_time').first()
+ newest_time = newest_group.create_time if newest_group else 1735660800 # 2025-01-01 00:00:00
+
+ load_result = {"update_count": 0, "add_count": 0}
+ group_iter = list_work_group(xy_client, chatName=chat_name)
+ async for group_list in async_split_generator(group_iter):
+ if group_list[-1].get('createTime') < newest_time:
+ break
+ logger.debug(f'处理群聊信息,chat_name: {chat_name}, {len(group_list)}')
+ result = await self.save_xingyun_group_info(group_list)
+ load_result['update_count'] += result['update_count']
+ load_result['add_count'] += result['add_count']
+ logger.debug(f'群聊数据更新结果,chat_name: {chat_name}, {result}')
+
+ if load_result['add_count'] + load_result['update_count'] > 300:
+ break
+
+ await self.refresh_xingyun_group_info()
+ return load_result
+
+ async def save_xingyun_group_info(self, group_list: list):
+ group_dict = {group.get('groupChatId'): group for group in group_list}
+ group_orm_list = await self.model.filter(chat_id__in=group_dict.keys())
+
+ update_list = []
+ history = {}
+ for group_orm in group_orm_list:
+ group = group_dict.pop(group_orm.chat_id, history.get(group_orm.chat_id, {}))
+ if not group: continue
+ history[group_orm.chat_id] = group
+
+ try:
+ group_in = WeixinGroupChatXingyunCreate(**group)
+ except Exception as e:
+ logger.error(f'群聊数据转换错误,chat_id: {group}, {e}')
+ continue
+ updated = False
+ if group_in.avatars:
+ group_orm.avatars = group_in.avatars
+ updated = True
+ if group_orm.xingyun_chat_id != group_in.xingyun_chat_id:
+ group_orm.xingyun_chat_id = group_in.xingyun_chat_id
+ updated = True
+ if updated:
+ update_list.append(group_orm)
+ if update_list:
+ await self.model.bulk_update(update_list, ['avatars', 'xingyun_chat_id'])
+
+ add_list = []
+ for chat_id, group in group_dict.items():
+ group_in = WeixinGroupChatXingyunCreate(**group)
+ add_list.append(self.model(**group_in.model_dump(exclude_unset=True)))
+
+ if add_list:
+ await self.model.bulk_create(add_list)
+
+ return {
+ 'update_count': len(update_list),
+ 'add_count': len(add_list),
+ }
+
+ # 刷新数据库中的群聊信息
+ async def refresh_xingyun_group_info(self):
+
+ for _ in range(10):
+ group_orm_list = await self.model.filter(xingyun_chat_id__isnull=False, external_user_count__isnull=True).order_by('-create_time').limit(20).all()
+ if not group_orm_list: break
+ logger.debug(f'刷新群聊数据,{len(group_orm_list)}')
+
+ for group_orm in group_orm_list:
+ try:
+ await self.get_external_group_chat_info(group_orm.chat_id)
+ except Exception as e:
+ logger.error(f'刷新群聊数据错误,chat_id: {group_orm.chat_id}, {e}')
+ continue
+ logger.debug(f'刷新群聊数据结果,chat_id: {group_orm.name} 已更新')
+
+ # 同步erp中的用户和客户信息到数据库
+ async def load_erp_order_info_by_group(self, buyer_nick: str, internal_member_list: list, external_member_list: list):
+ if not buyer_nick:
+ logger.error(f'群聊未命名,无法绑定订单,buyer_nick: {buyer_nick}')
+ return
+
+ orders = await async_generator_to_list(get_order_relative_user(lintao_client, buyer_nick=buyer_nick))
+ assert orders, f'未找到订单,buyer_nick: {buyer_nick}'
+
+ internal_member_dict = {user.get('name'): user for user in internal_member_list}
+
+ order = orders[0]
+
+ order_relative_user = order.get("users", [])
+ order_relative_user_dict = {user.get('name'): user for user in order_relative_user}
+
+ for name, user in order_relative_user_dict.items():
+ erp_id = user.get('id', '')
+ if not erp_id: continue
+ if re.match(r'^\d+$', erp_id) is None:
+ # 客户
+ if not external_member_list:
+ logger.error(f'客户群聊用户数为0,无法绑定客户, name: {name}, erp_id: {erp_id}')
+ continue
+ if name != buyer_nick:
+ logger.error(f'客户群聊用户与ERP卖家名称不一致,无法绑定客户, name: {name}, erp_id: {erp_id}')
+ continue
+ if len(external_member_list) != 1:
+ external_member_list.sort(key=lambda user: user.get('join_time'))
+ logger.warning(f'客户群聊用户数不是1个,将选取最先入群的用户作为客户,name: {name}, erp_id: {erp_id}')
+
+ for member in external_member_list:
+ member['order_id'] = order.get('trade_no')
+
+ weixin_user = external_member_list[0]
+ weixin_user.update({
+ 'order_id': order.get('trade_no'),
+ 'taobao_id': user.get('id', ''),
+ 'taobao_name': user.get('name', ''),
+ 'erp_message': user.get('message', ''),
+ 'erp_remark': user.get('memo', ''),
+ 'need_confirm': len(external_member_list) > 1,
+ })
+
+ continue
+ else:
+ # 员工
+ erp_id = int(erp_id)
+ weixin_user = internal_member_dict.get(name)
+ logger.debug(f'name: {name}, erp_id: {erp_id}, weixin_user: {weixin_user}')
+ if not weixin_user:
+ logger.error(f'{internal_member_dict.keys()} {name}')
+ logger.error(f'客户群聊用户与ERP员工名称不一致,无法绑定员工, name: {name}, erp_id: {erp_id}')
+ continue
+ weixin_user.update({
+ 'erp_id': erp_id,
+ 'erp_name': name,
+ 'role': user.get('role', ''),
+ })
+ continue
+
+ # 通过客户ID获取客户的所有订单
+ async def get_order_relative_user_list_by_weixin_userid(self, user_id: str):
+ customers = await weixin_customer_controller.model.filter(weixin_id=user_id)
+ if not customers:
+ await weixin_customer_controller.create({
+ "weixin_id": user_id,
+ })
+ # 新用户:触发数据同步事件
+ return False, []
+
+ order_list = []
+ buyer_set = set()
+ for customer in customers:
+ buyer_nick = customer.taobao_name
+ buyer_id = customer.taobao_id
+ if buyer_nick:
+ logger.debug(f'name: {user_id}; buyer_nick: {buyer_nick}')
+ if buyer_nick in buyer_set: continue
+ buyer_set.add(buyer_nick)
+ orders = await async_generator_to_list(get_order_relative_user(lintao_client, buyer_nick=buyer_nick))
+ elif buyer_id:
+ if buyer_id in buyer_set: continue
+ buyer_set.add(buyer_id)
+ orders = await async_generator_to_list(get_order_relative_user(lintao_client, buyer_id=buyer_id))
+ else:
+ assert buyer_nick, "当前客户未绑定淘宝账号"
+
+ logger.debug(f'orders.length: {len(orders)}')
+ order_list.extend(orders)
+
+ # for order in orders:
+ # 原地按照id排序
+ order_list.sort(key=lambda order: order.get('id'), reverse=True)
+ if order_list:
+ await self.get_user_detail_by_order(order=order_list[0])
+
+ return True, order_list
+
+ # 根据客户名字,获取群聊中客户的所有订单
+ async def get_order_relative_user_list_by_weixin_group_name(self, buyer_nick: str):
+ async with cache_if(f'order:relative_user:{buyer_nick}', ttl=10) as cache:
+ if cache.hit:
+ order_list = cache.value
+ logger.debug(f'{cache.key} hit')
+ else:
+ logger.debug(f'{cache.key} not hit')
+ order_list = await async_generator_to_list(get_order_relative_user(lintao_client, buyer_nick=buyer_nick))
+ cache.set(order_list)
+ logger.debug(f'order_list.length: {len(order_list)}')
+
+ # for order in order_list:
+ order_list.sort(key=lambda order: order.get('id'), reverse=True)
+ if order_list:
+ await self.get_user_detail_by_order(order=order_list[0])
+ order_list[0]['__type__'] = 'load_order_by_weixin_group_name'
+
+ return order_list
+
+ # 根据客户ID,获取群聊中客户的所有订单
+ async def get_order_relative_user_list_by_weixin_group_buyer_id(self, buyer_id: str):
+
+ orders = await async_generator_to_list(get_order_relative_user(lintao_client, buyer_id=buyer_id))
+ logger.debug(f'orders.length: {len(orders)}')
+
+ # for order in orders:
+ if orders:
+ await self.get_user_detail_by_order(order=orders[0])
+
+ return orders
+
+ async def get_user_detail_by_order(self, order_id: str='', order: dict = None):
+ if not order:
+ orders = await async_generator_to_list(get_order_relative_user(lintao_client, trade_no=order_id))
+ if not orders: return
+ _orders = [o for o in orders if o.get('ctid') == order_id]
+ # assert len(orders) == 1, f'订单号 {order_id} 对应多个订单'
+ if not _orders:
+ for order in orders:
+ logger.warning(f'订单号 {order_id} 对应多个订单,{order}')
+ # return {}
+ _orders = orders
+
+ order = _orders[0]
+
+ # 缓存订单产品信息
+ tid = order.get("ctid")
+ async with cache_if(f'order:product:{tid}', ttl=3600*24) as cache:
+ if cache.hit:
+ product_resp = cache.value
+ else:
+ product_resp = (await list_product_raw(lintao_client, tid=tid)).get('data', {})
+ if product_resp: cache.set(product_resp)
+
+ if product_resp:
+ order['product_title'] = product_resp[0].get("title")
+ order['product_pic'] = product_resp[0].get("pic_path")
+
+ order['has_fetched_user_detail'] = True
+ user_list = order.get("users", [])
+
+ async with cache_if(f'user:auto_relate', ttl=600) as cache:
+ if cache.hit:
+ default_user_list = cache.value
+ else:
+ default_user_list = await weixin_user_controller.all(Q(auto_relate=True))
+ default_user_list = [user.to_dict() for user in default_user_list]
+ cache.set(default_user_list)
+ default_user_set = {str(user.get('erp_id')) for user in default_user_list}
+
+ for user in user_list:
+ erp_id = user.get('id', '')
+ if erp_id in default_user_set: continue
+ await self.get_order_user_info(user)
+
+ order['users'].extend(default_user_list)
+
+ # 根据 role_list 中的顺序进行排序
+ role_order = {role: index for index, role in enumerate(ROLE_LIST)}
+ order['users'].sort(key=lambda user: role_order.get(user['role'], len(ROLE_LIST)))
+
+ return order
+
+ async def get_order_user_info(self, user: dict=None):
+ if not user: return
+ erp_id = user.get('id', '')
+ if not erp_id: return
+ if re.match(r'^\d+$', erp_id) is None:
+ # logger.debug(f'淘宝客户, erp_id: {erp_id}, user: {user}')
+ # 客户
+ taobao_id = user.get('id', '')
+ query = Q(taobao_id=taobao_id)
+ weixin_customer_list = await weixin_customer_controller.all(query)
+ if not weixin_customer_list:
+ logger.error(f'客户未绑定订单, taobao_id: {taobao_id}, user: {user}')
+ return
+ weixin_customer = weixin_customer_list[0]
+ buyer = weixin_customer.to_dict()
+ if not buyer.get('name') and user.get('name'):
+ weixin_customer.taobao_name = user.get('name')
+ await weixin_customer.save()
+ user.update({**buyer, **user})
+ else:
+ # 员工
+ erp_id = int(erp_id)
+ # logger.debug(f'员工, erp_id: {erp_id}, user: {user}')
+ query = Q(erp_id=erp_id)
+ weixin_user_list = await weixin_user_controller.all(query)
+ if not weixin_user_list:
+ user['erp_id'] = erp_id
+ logger.error(f'员工未绑定ERP, erp_id: {erp_id}, user: {user}')
+ return
+ weixin_user = weixin_user_list[0]
+ user.update(weixin_user.to_dict())
+
+ # 获取erp日志
+ async def get_erp_order_log(self, order_id: str):
+ return await get_order_log(lintao_client, order_id)
+
+ async def expire_user_cache(self, weixin_id: str):
+ await invalidate_cache(f'customer:user_datail:{weixin_id}')
+ await invalidate_cache(f'customer:user_datail_0:{weixin_id}')
+
+ # async def list_user_join_group(self, xingyun_id: int):
+ # assert xingyun_id, 'id 不能为空'
+ # result = await async_generator_to_list(list_user_join_group(xy_client, cid=xingyun_id))
+
+ # xingyun_chat_ids = [item.get('id') for item in result]
+ # all_group_chat = await self.model.filter(xingyun_chat_id__in=xingyun_chat_ids)
+ # group_chat_dict = {item.xingyun_chat_id: item for item in all_group_chat}
+ # logger.debug(f'list_user_join_group, xingyun_chat_ids: {xingyun_chat_ids}')
+
+ # remain_chat_ids = set()
+ # for item in result:
+ # group_chat_id = item.get('id')
+ # if group_chat_id in group_chat_dict: continue
+ # remain_chat_ids.add(group_chat_id)
+
+ # chat_name = item.get('chatName')
+ # if not chat_name: continue
+ # await self.load_xingyun_group_info(chat_name=chat_name)
+
+ # remain_chat_list = await self.model.filter(xingyun_chat_id__in=remain_chat_ids)
+ # all_group_chat.extend(remain_chat_list)
+ # return all_group_chat
+
+weixin_group_chat_controller = WeixinGroupChatController()
diff --git a/app/controllers/weixin/schedule.py b/app/controllers/weixin/schedule.py
new file mode 100644
index 0000000..f70a889
--- /dev/null
+++ b/app/controllers/weixin/schedule.py
@@ -0,0 +1,169 @@
+from .customer import weixin_customer_controller
+from .user import weixin_user_controller
+from app.controllers.msg import msg_controller
+from app.controllers.action import action_controller
+from app.schemas.weixin import WeixinGroupChatEvent, BindOrderResultEvent, CustomerRepeatPurchaseEvent, CustomerAssignOrderEvent, CustomerRefundOrderEvent
+from app.utils.common import transform_pydantic_to_list
+
+from datetime import datetime, timedelta
+from app.utils.event_task import event_manager, EventType
+import os
+import asyncio
+
+from typing import Any
+
+from apscheduler.schedulers.asyncio import AsyncIOScheduler as BackgroundScheduler
+
+from app.controllers.automation.scenario import automation_scenario_controller
+from app.controllers.automation.task import task_controller
+
+
+sync_lock = asyncio.Lock()
+import logging
+logger = logging.getLogger(__name__)
+
+async def sync_xingyun_contact_info_async(event_type, *args, **kwargs):
+ if os.getenv("APP_ENV") != "prod":
+ logger.info(f'非生产环境,不实际同步客户数据,{args} {kwargs}')
+ return
+
+ logger.info(f'同步客户数据,{args} {kwargs}')
+ now_time = datetime.now()
+ start_time = (now_time - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
+ end_time = now_time + timedelta(days=1)
+ await weixin_customer_controller.load_user_from_xingyun(add_time_start=start_time, add_time_end=end_time)
+
+async def load_all_user_from_xingyun(event_type, *args, **kwargs):
+ if os.getenv("APP_ENV") != "prod":
+ logger.info(f'非生产环境,不实际从星云加载全量客户数据,{args} {kwargs}')
+ return
+
+ logger.info(f'从星云加载全量客户数据,{args} {kwargs}')
+ start_time = datetime(2025, 6, 1)
+ end_time = datetime.now() + timedelta(days=1)
+ return await weixin_customer_controller.load_all_user_from_xingyun(task_id='load_all_user_from_xingyun', add_time_start=start_time, add_time_end=end_time)
+
+async def monitor_erp_order(event_type, *args, **kwargs):
+ if os.getenv("APP_ENV") != "prod":
+ logger.info(f'非生产环境,不实际从星云加载全量客户数据,{args} {kwargs}')
+ return
+
+ now_time = datetime.now()
+ logger.info(f'监控ERP订单,{args} {kwargs}')
+ new_order_list = [1]
+ new_order_list = await weixin_customer_controller.monitor_erp_order()
+ if new_order_list:
+ logger.info(f'监控到新订单,{new_order_list}')
+ await msg_controller.sync_msg()
+ logger.debug(f'耗时:{datetime.now() - now_time}')
+
+async def update_erp_order(*args, **kwargs):
+ logger.info(f'更新ERP订单,{args} {kwargs}')
+ await msg_controller.get_order_user_days_before(2)
+
+async def check_remark_action_is_done(*args, **kwargs):
+ if os.getenv("APP_ENV") != "prod":
+ logger.info(f'非生产环境,不实际检查备注操作是否完成,{args} {kwargs}')
+ return
+
+ logger.info(f'检查备注操作是否完成,{args} {kwargs}')
+ await action_controller.check_remark_action_is_done()
+
+ # 每天到点执行一次
+ today = datetime.now()
+ todayStart = today.replace(hour=0, minute=0, second=0, microsecond=0)
+ if today.hour in [23] and today.minute >= 50:
+ todayStart += timedelta(days=1)
+ class_type = '早班'
+ elif today.hour in [0, 1] and today.minute <= 10:
+ class_type = '早班'
+ elif today.hour in [16] and today.minute >= 50 or today.hour in [17,18] and today.minute <= 10:
+ class_type = '晚班'
+ else:
+ return
+
+ date = todayStart.strftime('%Y-%m-%d')
+ title = f'设置 {date} {class_type} 的员工活码'
+ success_title = title+' 成功'
+ fail_title = title+' 失败'
+
+ async with sync_lock:
+ has_set = await msg_controller.model.filter(title__in=[success_title]).first()
+ if has_set:
+ logger.info(f'【已操作】{title}')
+ return
+
+ logger.info(f'【开始】{title}')
+ try:
+ result, next_class = await weixin_user_controller.set_user_online(class_type=class_type, date=todayStart)
+ except Exception as e:
+ logger.exception(f'设置 {date} {class_type} 的员工活码失败,{e}')
+ result, next_class = {}, None
+
+ is_failed = bool(list(filter(lambda x: x.get('fail') != 0, result.values()))) or not result
+ logger.info(f'【完成】{title},结果:{result} {"失败" if is_failed else "成功"}')
+
+ result_str = '\n'.join([f'{k}:{v}' for k, v in result.items()])
+
+ logger.info(f'{success_title if not is_failed else fail_title},{result_str}')
+ content = f'\n\n{result_str}'
+ if result and not next_class:
+ content += f'\n\n⚠️ 未设置下一班值班人员,请及时设置'
+ logger.info(f'消息通知:{content}')
+ await msg_controller.new_system_msg(title=success_title if not is_failed else fail_title, content=content)
+ await msg_controller.sync_msg()
+
+async def group_chat_update(event_type, group_info, *args):
+ if os.getenv("APP_ENV") != "prod":
+ logger.info(f'非生产环境,不实际检查群聊更新,{group_info} {args}')
+ return
+
+ group_event = WeixinGroupChatEvent(**group_info)
+ await sync_xingyun_contact_info_async()
+
+
+async def test_event(event_type, event_data=None, *args, **kwargs):
+ # logger.info(f'测试事件,{args} {kwargs}')
+ # scenario_list = await automation_scenario_controller.find_active_scenarios(event_type, event_data=event_data)
+ # logger.info(f'触发事件,{len(scenario_list)}')
+ # for scenario in scenario_list:
+ # task = await task_controller.create_from_scenario(scenario=scenario, event_data=event_data)
+ # logger.info(f'创建任务,{task}')
+ pass
+
+async def trigger_event(event_type: str, event_data: Any):
+ """解析事件数据"""
+ logger.info(f'测试事件,{event_type} {event_data}')
+ scenario_list = await automation_scenario_controller.find_active_scenarios(event_type, event_data=event_data)
+ logger.info(f'触发事件,{len(scenario_list)}')
+ for scenario, reason in scenario_list:
+ logger.info(f'触发场景,{scenario},{reason}')
+ try:
+ task = await task_controller.create_from_scenario(scenario=scenario, event_data=event_data, reason=reason)
+ logger.info(f'创建任务,{task}')
+ except Exception as e:
+ logger.error(f'创建任务失败,{e}')
+
+# print(f'事件订阅:{transform_pydantic_to_list(WeixinGroupChatEvent)}')
+
+event_manager.subscribe(EventType.SYNC_XINGYUN_CONTACT_INFO, load_all_user_from_xingyun)
+event_manager.subscribe(EventType.OPEN_PERSONAL_CHAT, sync_xingyun_contact_info_async)
+event_manager.subscribe(EventType.GROUP_CHAT_UPDATED, group_chat_update, input_model=transform_pydantic_to_list(WeixinGroupChatEvent))
+event_manager.subscribe(EventType.BIND_ORDER_FOR_USER, sync_xingyun_contact_info_async, input_model=transform_pydantic_to_list(BindOrderResultEvent))
+event_manager.subscribe(EventType.OLD_CUSTOMER_REPEAT_PURCHASE, test_event, input_model=transform_pydantic_to_list(CustomerRepeatPurchaseEvent))
+event_manager.subscribe(EventType.CUSTOMER_SERVICE_ASSIGN_ORDER, test_event, input_model=transform_pydantic_to_list(CustomerAssignOrderEvent))
+event_manager.subscribe(EventType.DESIGNER_ASSIGN_ORDER, test_event, input_model=transform_pydantic_to_list(CustomerAssignOrderEvent))
+event_manager.subscribe(EventType.CUSTOMER_RETURN_ORDER, test_event, input_model=transform_pydantic_to_list(CustomerRefundOrderEvent))
+event_manager.subscribe(EventType.DESIGNER_UPLOAD_DESIGN, test_event)
+
+event_manager.register_pretask(trigger_event)
+
+if os.getenv("APP_ENV") == "prod":
+ logger.info('生产环境,启动定时任务')
+ # 创建调度器
+ scheduler = BackgroundScheduler()
+
+ # scheduler.add_job(monitor_erp_order, "interval", seconds=60) # 每90秒执行一次
+ scheduler.add_job(update_erp_order, "interval", seconds=240) # 每90秒执行一次
+ scheduler.add_job(check_remark_action_is_done, "interval", seconds=180) # 每90秒执行一次
+ scheduler.start()
diff --git a/app/controllers/weixin/user.py b/app/controllers/weixin/user.py
new file mode 100644
index 0000000..ab1c01e
--- /dev/null
+++ b/app/controllers/weixin/user.py
@@ -0,0 +1,201 @@
+
+from app.core.crud import CRUDBase
+from app.schemas.weixin import (
+ WeixinUserCreate,
+ WeixinUserUpdate,
+)
+
+from app.models.weixin import WeixinUser
+from app.schemas.weixin import WeixinUserBindInfo
+
+from some_sdk.services.binder import feishu_client, xy_client
+from some_sdk.feishu_sdk.biz.doc import search_file_record
+from some_sdk.xingyun_sdk.apis.channel import list_channel_group, list_channel_group_user_list, set_online_staff
+
+import logging
+logger = logging.getLogger(__name__)
+
+from datetime import datetime
+
+def split_generator(iterable, chunk_size=50):
+ """
+ 将可迭代对象拆分为多个子列表
+ """
+ chunk = []
+ for item in iterable:
+ chunk.append(item)
+ if len(chunk) == chunk_size:
+ yield chunk
+ chunk = []
+ if chunk:
+ yield chunk
+
+class WeixinUserController(CRUDBase[WeixinUser, WeixinUserCreate, WeixinUserUpdate]):
+ def __init__(self):
+ super().__init__(model=WeixinUser)
+
+ async def update_from_group(self, internal_members: list[dict]):
+ # 同步更新数据库
+ member_dict = {}
+ for member in internal_members:
+ member = member.copy()
+ userid = member.get('userid', None)
+ member['alias'] = member.pop('name', None)
+ member_dict[userid] = member
+
+ member_list = await self.model.filter(userid__in=member_dict.keys())
+
+ update_list = []
+ update_fileds = ['alias', 'role', 'erp_id', 'erp_name']
+
+ history = {}
+ for member in member_list:
+ member_info = member_dict.pop(member.userid, history.get(member.userid, {}))
+ if member.role in ['owner', 'admin', 'follower']:
+ continue
+ if not member_info: continue
+ history[member.userid] = member_info
+
+ updated = {}
+ for field in update_fileds:
+ old_value = getattr(member, field)
+ if field not in member_info:
+ continue
+ value = member_info.get(field)
+ if old_value != value:
+ setattr(member, field, value)
+ updated[f'{field}:{str(old_value)}'] = value
+ if updated:
+ logger.info(f'用户 {member.userid} 更新关系为 {updated}')
+ update_list.append(member)
+ if update_list:
+ await self.model.bulk_update(update_list, fields=update_fileds)
+
+ # 新增用户
+ create_list = []
+ for userid, member_info in member_dict.items():
+ create_list.append(WeixinUser(
+ **member_info,
+ name=member_info.get('alias'),
+ ))
+ if create_list:
+ await self.model.bulk_create(create_list)
+
+ return {
+ 'update_count': len(update_list),
+ 'create_count': len(create_list),
+ }
+
+
+ async def bind_user(self, user_in: WeixinUserBindInfo):
+ if not user_in.userid:
+ raise ValueError("用户ID不能为空")
+ if user_in.erp_id == 0:
+ del user_in.erp_id
+
+ user = await self.model.filter(userid=user_in.userid).first()
+ if not user:
+ await self.create({**user_in.model_dump(exclude_unset=True), 'name': user_in.username})
+ return {}
+ await self.model.filter(userid=user_in.userid).update(**user_in.model_dump(exclude_unset=True))
+
+ async def set_user_online(self, class_type: str, date: datetime):
+ daysTimestamp = lambda nowStamp, days: nowStamp + days * 24 * 60 * 60 * 1000
+ formatDate = lambda ts: datetime.fromtimestamp(ts / 1000).strftime('%Y-%m-%d')
+
+ today = int(date.timestamp() * 1000)
+ logger.info(f'设置 {formatDate(today)} 班次为 {class_type} 的员工为在线状态')
+ yestoday = daysTimestamp(today, -1)
+ tomorrow = daysTimestamp(today, 1)
+
+ # 查询日期为 yestoday 到 后天 之间的所有记录
+ resp = search_file_record(
+ feishu_client,
+ app_token="V4qebMG2kamCWMslJmdcQMbtngW",
+ table_id="tblKgjc3F2jZ2inC",
+ filter={
+ "conjunction": "and",
+ "conditions": [{
+ "field_name": "日期",
+ "operator": "isGreater",
+ "value": ["ExactDate", yestoday]
+ },{
+ "field_name": "日期",
+ "operator": "isLess",
+ "value": ["ExactDate", daysTimestamp(today, 2)]
+ }]
+ },
+ )
+
+ set_result = {}
+ current_class = (class_type, today)
+ next_class = ('早班', tomorrow) if class_type == '晚班' else ('晚班', today)
+
+ current_class_mapping = {}
+ next_class_mapping = {}
+ async for data in resp:
+ user = data.get('data', {})
+ user_online = ((user.get('班次') or '')[:2], user.get('日期', ''))
+ # logger.info(f'用户{user.get("接量成员", "")} 在线时间为{user_online} 当前班次为{current_class} 下一班次为{next_class}')
+ class_group = user.get('组别', '')
+ class_mapping = None
+ # 当前班次
+ if user_online == current_class:
+ class_mapping = current_class_mapping
+ set_result[class_group] = {"接量成员": user.get('接量成员', '')}
+ # 下一班次
+ elif user_online == next_class:
+ class_mapping = next_class_mapping
+ if class_mapping is None:
+ continue
+
+ if user.get('id', ''):
+ class_mapping.setdefault(class_group, [])
+ class_mapping[class_group].append(user.get('id', ''))
+
+ assert current_class_mapping, f"根据日期{current_class[1]} {formatDate(current_class[1])}没有找到班次为{class_type}的员工"
+ logger.info(f'根据日期{current_class[1]} {formatDate(current_class[1])} 找到班次为{class_type}的员工: {current_class_mapping} {set_result}')
+ if next_class_mapping:
+ logger.info(f'根据日期{next_class[1]} {formatDate(next_class[1])} 找到班次为{next_class[0]}的员工: {next_class_mapping}')
+
+ group_list = list_channel_group(xy_client)
+ async for group in group_list:
+ title = group.get("title", "")
+ if title not in current_class_mapping:
+ continue
+
+ set_result[title] = set_result.get(title) or {}
+ try:
+ user_list = list_channel_group_user_list(xy_client, groupId=group.get("id", ""))
+
+ huomaList = []
+ async for user in user_list:
+ huomaList.append(user.get('id'))
+
+ if not huomaList:
+ logger.error(f'组 {title} {group.get("id", "")} 中没有员工')
+ continue
+
+ is_faild, is_success = [], []
+ # 按照50个一组设置在线状态
+ for i in range(0, len(huomaList), 50):
+ result = await set_online_staff(xy_client, allDayUserIds=','.join(current_class_mapping[title]), huomaList=huomaList[i:i+50])
+
+ is_faild += list(filter(lambda x: x.get('errorMsg', ''), result.get('data', [])))
+ is_success += list(filter(lambda x: x.get('success', True), result.get('data', [])))
+
+ is_faild = set(is_faild)
+ if is_faild: logger.error(f'设置失败的员工: {is_faild}')
+ else: logger.info(f'全部员工设置成功')
+
+ set_result[title] = {
+ 'success': len(is_success),
+ 'fail': len(is_faild),
+ **set_result[title]
+ }
+ except Exception as e:
+ logger.exception(f'设置群 {group.get("id", "")} 中员工 {huomaList} 为在线状态失败: {e}')
+
+ return set_result, next_class_mapping
+
+weixin_user_controller = WeixinUserController()
diff --git a/app/controllers/weixin/utils.py b/app/controllers/weixin/utils.py
new file mode 100644
index 0000000..c8c48bf
--- /dev/null
+++ b/app/controllers/weixin/utils.py
@@ -0,0 +1,4 @@
+def get_buyer_nick_from_group_name(group_name: str):
+ buyer_nick = group_name.rsplit('-', 1)[0] if '服务群' in group_name else group_name
+ return buyer_nick
+
diff --git a/app/controllers/weixin_sdk.py b/app/controllers/weixin_sdk.py
new file mode 100644
index 0000000..d0b233f
--- /dev/null
+++ b/app/controllers/weixin_sdk.py
@@ -0,0 +1,20 @@
+from some_sdk.weixin_sdk.session import get_logged_in_client
+from some_sdk.weixin_sdk.apis.userlist import list_department_user, get_user_detail_by_vid
+
+# 首次调用:登录并创建 client
+client = get_logged_in_client()
+
+def test_list_department_user(partyid: str):
+ department_list = list_department_user(client, partyid)
+ for index, department in enumerate(department_list, 1):
+ print(f'{index}. {department}')
+ print('==== ' * 10)
+
+ return
+
+
+if __name__ == "__main__":
+ test_list_department_user('1688852859949799')
+
+
+
diff --git a/app/controllers/xingyun_sdk.py b/app/controllers/xingyun_sdk.py
new file mode 100644
index 0000000..7bab3ca
--- /dev/null
+++ b/app/controllers/xingyun_sdk.py
@@ -0,0 +1,66 @@
+from some_sdk.xingyun_sdk.session import get_logged_in_client
+from some_sdk.xingyun_sdk.biz.by_user import get_user_order
+from .wechat_sdk import get_external_group_chat_info
+from app.models.weixin import WeixinUser
+
+# 首次调用:登录并创建 client
+client = get_logged_in_client()
+
+def get_user_order_list(group_name: str="", keyword: str="", regrex: str=None, userIds: list = None):
+ result = get_user_order(client, keyword=keyword, group_name=group_name, regrex=regrex, userIds=userIds)
+ if not result:
+ return {}
+
+ order_list = result.pop("order_list", [])
+ print(f'在群聊 {result["group_name"]} 中的客户 {result["name"]}({result["keyword"]}) 有订单数: {len(order_list)}')
+
+ subTradeNo_list = []
+ for order in order_list:
+ result_order = dict(tradeNo='', tradeTime=order.get("tradeTime", ''), payTime=order.get("payTime", ''))
+ orderList = order.get("orderList", [])
+
+ for item in orderList:
+ result_order['shopName'] = order.get("shopName", "")
+ result_order['goodsName'] = item.get("goodsName", "")
+ result_order['actuPayment'] = order.get("actuPayment", "")
+ result_order['goodsCount'] = order.get("goodsCount", "")
+ result_order['tradeNo'] = item.get("subTradeNo", "")
+ result_order['pic'] = item.get("pic", "")
+ result_order['other_status'] = order.get("orderStatus", 0)
+ result_order['other_status_name'] = order.get("status_name", "")
+ subTradeNo_list.append(result_order.copy())
+
+ import json; print(json.dumps(subTradeNo_list, indent=4, ensure_ascii=False))
+ result['order_list'] = subTradeNo_list
+ return result
+
+async def get_user_order_list_by_chatid(chat_id: str):
+ group_chat_info = await get_external_group_chat_info(chat_id)
+ external_member_list = group_chat_info.get('external_member_list', [])
+
+ trade_list = []
+ group_chat_info['trade_list'] = trade_list
+
+ invitor = group_chat_info.get('owner')
+ qiwei_kefu = await WeixinUser.filter(userid=invitor).first()
+ print(f'邀请人:{invitor} {qiwei_kefu.username} {qiwei_kefu.crmid}')
+
+ group_name = group_chat_info.get('name')
+ # if not group_name:
+ # group_name = '未知群聊'
+
+ print(f'群聊名称{group_name},有{len(external_member_list)}位客户', flush=True)
+ for user in external_member_list:
+ print(f'正在处理客户:{user.get("name")}', flush=True)
+ result = get_user_order_list(group_name=group_name, keyword=user.get('name'), userIds=[qiwei_kefu.crmid] if qiwei_kefu.crmid else None)
+ if not result:
+ continue
+
+ trade_list.append(result)
+
+ return group_chat_info
+
+if __name__ == "__main__":
+ get_user_order("fuyixuan0628-印刷vip客户服务群", "去看海吗")
+ get_user_order("小旋honey-印刷vip客户服务群@喜印说", "宇宙大女神")
+
diff --git a/app/core/bgtask.py b/app/core/bgtask.py
new file mode 100644
index 0000000..6d5b6fe
--- /dev/null
+++ b/app/core/bgtask.py
@@ -0,0 +1,31 @@
+from starlette.background import BackgroundTasks
+
+from .ctx import CTX_BG_TASKS
+
+
+class BgTasks:
+ """后台任务统一管理"""
+
+ @classmethod
+ async def init_bg_tasks_obj(cls):
+ """实例化后台任务,并设置到上下文"""
+ bg_tasks = BackgroundTasks()
+ CTX_BG_TASKS.set(bg_tasks)
+
+ @classmethod
+ async def get_bg_tasks_obj(cls):
+ """从上下文中获取后台任务实例"""
+ return CTX_BG_TASKS.get()
+
+ @classmethod
+ async def add_task(cls, func, *args, **kwargs):
+ """添加后台任务"""
+ bg_tasks = await cls.get_bg_tasks_obj()
+ bg_tasks.add_task(func, *args, **kwargs)
+
+ @classmethod
+ async def execute_tasks(cls):
+ """执行后台任务,一般是请求结果返回之后执行"""
+ bg_tasks = await cls.get_bg_tasks_obj()
+ if bg_tasks.tasks:
+ await bg_tasks()
diff --git a/app/core/cache.py b/app/core/cache.py
new file mode 100644
index 0000000..e6d0f3a
--- /dev/null
+++ b/app/core/cache.py
@@ -0,0 +1,163 @@
+# core/cache.py
+import json as _stdlib_json
+from contextlib import asynccontextmanager
+from typing import Any, AsyncGenerator, Optional, Union
+
+import logging
+logger = logging.getLogger(__name__)
+
+from redis.asyncio import Redis
+
+# ===== 尝试使用你的 fff,否则回退到 stdlib =====
+try:
+ import orjson as json
+except ImportError:
+ json = _stdlib_json # type: ignore
+
+
+# ===== Redis 客户端单例(可替换为你自己的)=====
+class RedisClient:
+ _instance: Optional["RedisClient"] = None
+ _redis: Optional[Redis] = None
+
+ def __new__(cls):
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ return cls._instance
+
+ async def init_redis(self, url: str = "redis://localhost:6379/0") -> None:
+ if self._redis is None:
+ self._redis = Redis.from_url(url, decode_responses=False)
+
+ @property
+ def client(self) -> Redis:
+ if self._redis is None:
+ raise RuntimeError("Redis not initialized. Call init_redis() first.")
+ return self._redis
+
+ async def close(self) -> None:
+ if self._redis:
+ await self._redis.close()
+ self._redis = None
+
+
+redis_client = RedisClient()
+
+
+# ===== FastAPI 依赖注入 =====
+async def get_redis() -> Redis:
+ """FastAPI 依赖:获取 Redis 客户端"""
+ return redis_client.client
+
+async def invalidate_cache(key: str) -> bool:
+ """
+ 主动删除缓存键。
+ 返回是否成功删除(Redis delete 返回被删除的 key 数量)。
+ """
+ try:
+ result = await redis_client.client.delete(key)
+ return result > 0
+ except Exception as e:
+ logger.exception(f"Failed to invalidate cache key: {key}", exc_info=e)
+ return False
+
+# ===== 缓存上下文管理器 =====
+class CacheResult:
+ __slots__ = ("key", "hit", "value", "_to_set", "_set_called")
+
+ def __init__(self, key: str) -> None:
+ self.key = key
+ self.hit = False
+ self.value: Any = None
+ self._to_set: Any = None
+ self._set_called = False
+
+ def set(self, value: Any) -> None:
+ """标记要缓存的值(可为 None)"""
+ self._to_set = value
+ self._set_called = True
+
+
+@asynccontextmanager
+async def cache_if(
+ key: str,
+ ttl: int = 3600,
+ redis: Optional[Redis] = None,
+) -> AsyncGenerator[CacheResult, None]:
+ """
+ 异步缓存上下文管理器,支持 None 值缓存(防穿透)。
+
+ Args:
+ key: 缓存键
+ ttl: 正常值缓存时间(秒)
+ redis: 可选 Redis 客户端(用于测试或自定义)
+
+ Usage:
+ async with cache_if("report:123") as cache:
+ if cache.hit:
+ return cache.value
+ result = await compute()
+ cache.set(result) # result 可为 None
+ """
+ result = CacheResult(key)
+ client = redis or redis_client.client
+
+ # 尝试读缓存
+ try:
+ cached_val = await client.get(key)
+ if cached_val is not None:
+ # 解码
+ if cached_val == b"__NULL__":
+ result.hit = True
+ result.value = None
+ else:
+ result.hit = True
+ result.value = json.loads(cached_val)
+ except Exception:
+ # Redis 不可用,降级(不中断主流程)
+ logger.exception(f"Redis get error for key: {key}")
+ pass
+
+ yield result
+
+ # 写缓存(仅当调用了 set())
+ if result._set_called:
+ try:
+ if result._to_set is None:
+ val = b"__NULL__"
+ ex = 60 # 空值短 TTL
+ else:
+ # 注意:orjson.dumps 返回 bytes,stdlib 返回 str → 统一转 bytes
+ serialized = json.dumps(result._to_set)
+ val = serialized if isinstance(serialized, bytes) else serialized.encode("utf-8")
+ ex = ttl
+ await client.setex(key, ex, val)
+ except Exception:
+ # 写缓存失败,不影响主流程
+ pass
+
+
+# ===== 装饰器版(可选补充)=====
+from functools import wraps
+import asyncio
+import hashlib
+
+def cached(ttl: int = 3600):
+ """函数缓存装饰器(使用 cache_if)"""
+ def decorator(func):
+ @wraps(func)
+ async def wrapper(*args, **kwargs):
+ # 生成 key(简单版,可替换为更 robust 的)
+ key_data = str(args) + str(sorted(kwargs.items()))
+ key = f"cached:{func.__name__}:{hashlib.md5(key_data.encode()).hexdigest()}"
+ logger.debug(f"Cache key: {key}")
+
+ async with cache_if(key, ttl) as cache:
+ if cache.hit:
+ logger.debug(f"Cache hit for key: {key}")
+ return cache.value
+ result = await func(*args, **kwargs)
+ cache.set(result)
+ return result
+ return wrapper
+ return decorator
\ No newline at end of file
diff --git a/app/core/crud.py b/app/core/crud.py
new file mode 100644
index 0000000..ae2ee40
--- /dev/null
+++ b/app/core/crud.py
@@ -0,0 +1,78 @@
+from typing import Any, Callable, Dict, Generic, List, NewType, Tuple, Type, TypeVar, Union
+
+from pydantic import BaseModel
+from tortoise.expressions import Q
+from tortoise.models import Model
+
+Total = NewType("Total", int)
+ModelType = TypeVar("ModelType", bound=Model)
+CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
+UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
+
+
+class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
+ def __init__(self, model: Type[ModelType]):
+ self.model = model
+
+ async def is_exist(self, **kwargs) -> bool:
+ return await self.model.filter(**kwargs).first()
+
+ async def all(self, search: Q = Q()) -> List[ModelType]:
+ return await self.model.filter(search).all()
+
+ async def get(self, id: int) -> ModelType:
+ return await self.model.get(id=id)
+
+ async def list(self, page: int, page_size: int, search: Q = Q(), order: list = []) -> Tuple[Total, List[ModelType]]:
+ query = self.model.filter(search)
+ return await query.count(), await query.offset((page - 1) * page_size).limit(page_size).order_by(*order)
+
+ async def create(self, obj_in: CreateSchemaType) -> ModelType:
+ if isinstance(obj_in, Dict):
+ obj_dict = obj_in
+ else:
+ obj_dict = obj_in.model_dump()
+ obj = self.model(**obj_dict)
+ await obj.save()
+ return obj
+
+ async def copy(self, id: int, handler: Callable[[Dict[str, Any]], Dict[str, Any]] = None) -> ModelType:
+ obj = await self.get(id=id)
+ obj_dict = await obj.to_dict()
+ drop_keys = ["id", "created_at", "updated_at"]
+ for key in drop_keys:
+ obj_dict.pop(key, None)
+ if handler:
+ obj_dict = handler(obj_dict)
+ obj = self.model(**obj_dict)
+ await obj.save()
+ return obj
+
+ async def update(self, id: int, obj_in: Union[UpdateSchemaType, Dict[str, Any]]) -> ModelType:
+ if isinstance(obj_in, Dict):
+ obj_dict = obj_in
+ else:
+ obj_dict = obj_in.model_dump(exclude_unset=True, exclude={"id"})
+ obj = await self.get(id=id)
+ obj = obj.update_from_dict(obj_dict)
+ await obj.save()
+ return obj
+
+ async def create_or_update(self, obj_in: CreateSchemaType, query_kwargs: Dict[str, Any], update_kwargs: Dict[str, Any] = None) -> ModelType:
+ update_kwargs = update_kwargs or {}
+ orm = await self.model.filter(**query_kwargs).first()
+ if orm:
+ need_update = False if update_kwargs else True
+ for key, value in update_kwargs.items():
+ if getattr(orm, key) != value:
+ need_update = True
+ break
+ if need_update:
+ return 'update', await self.update(orm.id, obj_in)
+ else:
+ return False, orm
+ return 'create', await self.create(obj_in)
+
+ async def remove(self, id: int) -> None:
+ obj = await self.get(id=id)
+ await obj.delete()
diff --git a/app/core/ctx.py b/app/core/ctx.py
new file mode 100644
index 0000000..c97a0fc
--- /dev/null
+++ b/app/core/ctx.py
@@ -0,0 +1,17 @@
+import contextvars
+
+from starlette.background import BackgroundTasks
+
+CTX_USER_ID: contextvars.ContextVar[int] = contextvars.ContextVar("user_id", default=0)
+CTX_USER_NAME: contextvars.ContextVar[str] = contextvars.ContextVar("user_name", default="")
+CTX_BG_TASKS: contextvars.ContextVar[BackgroundTasks] = contextvars.ContextVar("bg_task", default=None)
+
+def set_ctx_weixin_user(user_id: int, user_name: str):
+ CTX_USER_ID.set(user_id)
+ CTX_USER_NAME.set(user_name)
+
+def get_ctx_weixin_user():
+ return {
+ "user_id": CTX_USER_ID.get(),
+ "user_name": CTX_USER_NAME.get(),
+ }
diff --git a/app/core/dependency.py b/app/core/dependency.py
new file mode 100644
index 0000000..fb454db
--- /dev/null
+++ b/app/core/dependency.py
@@ -0,0 +1,69 @@
+from typing import Optional
+
+import jwt
+from fastapi import Depends, Header, HTTPException, Request
+
+from app.core.ctx import CTX_USER_ID
+from app.models import Role, User, WeixinUser
+from app.settings import settings
+
+
+class AuthControl:
+ @classmethod
+ async def is_authed(cls, token: str = Header(..., description="token验证")) -> Optional["User"]:
+ try:
+ if token == "dev":
+ user = await User.filter().first()
+ user_id = user.id
+ else:
+ decode_data = jwt.decode(token, settings.SECRET_KEY, algorithms=settings.JWT_ALGORITHM)
+ user_id = decode_data.get("user_id")
+ user = await User.filter(id=user_id).first()
+ if not user:
+ raise HTTPException(status_code=401, detail="Authentication failed")
+ CTX_USER_ID.set(int(user_id))
+ return user
+ except jwt.DecodeError:
+ raise HTTPException(status_code=401, detail="无效的Token")
+ except jwt.ExpiredSignatureError:
+ raise HTTPException(status_code=401, detail="登录已过期")
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"{repr(e)}")
+
+ @classmethod
+ async def weixin_user(cls, request: Request) -> WeixinUser:
+ path = request.url.path
+ if path in [
+ "/api/v1/msg/new_order",
+ "/api/v1/event/feishu",
+ "/api/v1/msg/get_order_user_days_before",
+ ]:
+ return None
+
+ token = request.headers.get("token")
+ user = await WeixinUser.filter(userid=token).first()
+ assert user, "Authentication failed"
+ CTX_USER_ID.set(int(user.id))
+ return user # 返回的是 WeixinUser 实例
+
+class PermissionControl:
+ @classmethod
+ async def has_permission(cls, request: Request, current_user: User = Depends(AuthControl.is_authed)) -> None:
+ if current_user.is_superuser:
+ return
+ method = request.method
+ path = request.url.path
+ roles: list[Role] = await current_user.roles
+ if not roles:
+ raise HTTPException(status_code=403, detail="The user is not bound to a role")
+ apis = [await role.apis for role in roles]
+ permission_apis = list(set((api.method, api.path) for api in sum(apis, [])))
+ # path = "/api/v1/auth/userinfo"
+ # method = "GET"
+ if (method, path) not in permission_apis:
+ raise HTTPException(status_code=403, detail=f"Permission denied method:{method} path:{path}")
+
+
+DependAuth = Depends(AuthControl.is_authed)
+DependWeixinUser = Depends(AuthControl.weixin_user)
+DependPermisson = Depends(PermissionControl.has_permission)
diff --git a/app/core/exceptions.py b/app/core/exceptions.py
new file mode 100644
index 0000000..af4f17a
--- /dev/null
+++ b/app/core/exceptions.py
@@ -0,0 +1,150 @@
+from fastapi.exceptions import (
+ HTTPException,
+ RequestValidationError,
+ ResponseValidationError,
+)
+from fastapi.requests import Request
+from fastapi.responses import JSONResponse
+from tortoise.exceptions import DoesNotExist, IntegrityError
+
+from app.http_base import HttpResp
+
+import logging
+logger = logging.getLogger(__name__)
+
+class SettingNotFound(Exception):
+ pass
+
+
+class AssertException(Exception):
+ """断言异常"""
+ def __init__(self, message: str, code: int = 1001):
+ self.message = message
+ self.code = code
+ super().__init__(message)
+
+
+async def DoesNotExistHandle(req: Request, exc: DoesNotExist) -> JSONResponse:
+ """处理对象不存在异常"""
+ resp = HttpResp.REQUEST_404_ERROR
+ content = dict(
+ code=resp.code,
+ msg=f"Object has not found, exc: {exc}, query_params: {req.query_params}",
+ data=None
+ )
+ logger.warning(f"Object not found: {exc}, path: {req.url.path}")
+ return JSONResponse(content=content, status_code=200)
+
+
+async def IntegrityHandle(req: Request, exc: IntegrityError) -> JSONResponse:
+ """处理数据完整性异常"""
+ resp = HttpResp.SYSTEM_ERROR
+ content = dict(
+ code=resp.code,
+ msg=f"IntegrityError: {str(exc)}",
+ data=None
+ )
+ logger.error(f"Integrity error: {exc}, path: {req.url.path}", exc_info=True)
+ return JSONResponse(content=content, status_code=200)
+
+
+async def HttpExcHandle(req: Request, exc: HTTPException) -> JSONResponse:
+ """处理 HTTP 异常"""
+ content = dict(
+ code=exc.status_code,
+ msg=exc.detail,
+ data=None
+ )
+ logger.warning(f"HTTP exception: {exc.status_code} - {exc.detail}, path: {req.url.path}")
+ return JSONResponse(content=content, status_code=200)
+
+
+async def RequestValidationHandle(req: Request, exc: RequestValidationError) -> JSONResponse:
+ """处理请求验证异常"""
+ resp = HttpResp.PARAMS_VALID_ERROR
+ errors = []
+ for error in exc.errors():
+ errors.append({
+ "loc": error["loc"],
+ "msg": error["msg"],
+ "type": error["type"]
+ })
+
+ content = dict(
+ code=resp.code,
+ msg=f"{resp.msg}, errors: {errors}",
+ data=errors
+ )
+ logger.warning(f"Request validation error: {errors}, path: {req.url.path}")
+ return JSONResponse(content=content, status_code=200)
+
+
+async def ResponseValidationHandle(req: Request, exc: ResponseValidationError) -> JSONResponse:
+ """处理响应验证异常"""
+ resp = HttpResp.SYSTEM_ERROR
+ content = dict(
+ code=resp.code,
+ msg=f"Response validation error: {str(exc)}",
+ data=None
+ )
+ logger.error(f"Response validation error: {exc}, path: {req.url.path}", exc_info=True)
+ return JSONResponse(content=content, status_code=200)
+
+
+async def AssertExceptionHandle(request: Request, exc: AssertException) -> JSONResponse:
+ """处理断言异常"""
+ resp = HttpResp.ASSERT_ARGUMENT_ERROR
+ content = dict(
+ code=exc.code,
+ msg=exc.message,
+ data=None
+ )
+ logger.warning(f"Assert exception: {exc.message}, path: {request.url.path}")
+ return JSONResponse(
+ content=content,
+ status_code=200
+ )
+
+
+async def AssertionErrorHandle(request: Request, exc: AssertionError) -> JSONResponse:
+ """处理 Python assert 异常"""
+ resp = HttpResp.ASSERT_ARGUMENT_ERROR
+ message = str(exc) if exc.args else resp.msg
+ content = dict(
+ code=resp.code,
+ msg=message,
+ data=None
+ )
+ logger.warning(f"Assertion error: {message}, path: {request.url.path}")
+ return JSONResponse(
+ content=content,
+ status_code=200
+ )
+
+
+async def GlobalExceptionHandler(request: Request, exc: Exception) -> JSONResponse:
+ """全局异常处理器"""
+ resp = HttpResp.SYSTEM_ERROR
+ content = dict(
+ code=resp.code,
+ msg=f"{resp.msg}: {str(exc)}",
+ data=None
+ )
+ logger.error(f"Global exception: {str(exc)}, path: {request.url.path}", exc_info=True)
+ return JSONResponse(
+ content=content,
+ status_code=200
+ )
+
+
+# 便捷函数用于注册所有异常处理器
+def register_exception_handlers(app):
+ """注册所有异常处理器到 FastAPI 应用"""
+ app.add_exception_handler(DoesNotExist, DoesNotExistHandle)
+ app.add_exception_handler(IntegrityError, IntegrityHandle)
+ app.add_exception_handler(HTTPException, HttpExcHandle)
+ app.add_exception_handler(RequestValidationError, RequestValidationHandle)
+ app.add_exception_handler(ResponseValidationError, ResponseValidationHandle)
+ app.add_exception_handler(AssertException, AssertExceptionHandle)
+ app.add_exception_handler(AssertionError, AssertionErrorHandle) # 捕获 Python assert
+ app.add_exception_handler(Exception, GlobalExceptionHandler) # 全局异常处理器
diff --git a/app/core/init_app.py b/app/core/init_app.py
new file mode 100644
index 0000000..9f000ea
--- /dev/null
+++ b/app/core/init_app.py
@@ -0,0 +1,273 @@
+import shutil
+
+from aerich import Command
+from fastapi import FastAPI
+from fastapi.middleware import Middleware
+from fastapi.middleware.cors import CORSMiddleware
+from tortoise.expressions import Q
+
+from app.api import api_router
+from app.controllers.api import api_controller
+from app.controllers.user import UserCreate, user_controller
+from app.core.exceptions import register_exception_handlers
+from app.log import logger
+from app.models.admin import Api, Menu, Role, Dept
+from app.models.automation import Task, Scenario
+from app.schemas.menus import MenuType
+from app.settings.config import settings
+from app.core.cache import redis_client
+
+from .middlewares import BackGroundTaskMiddleware, HttpAuditLogMiddleware
+
+
+def make_middlewares():
+ middleware = [
+ Middleware(
+ CORSMiddleware,
+ allow_origins=settings.CORS_ORIGINS,
+ allow_credentials=settings.CORS_ALLOW_CREDENTIALS,
+ allow_methods=settings.CORS_ALLOW_METHODS,
+ allow_headers=settings.CORS_ALLOW_HEADERS,
+ ),
+ Middleware(BackGroundTaskMiddleware),
+ Middleware(
+ HttpAuditLogMiddleware,
+ methods=["GET", "POST", "PUT", "DELETE"],
+ exclude_paths=[
+ "/api/v1/msg/new_order",
+ "/api/v1/weixin/jssdk-config",
+ "/api/v1/base/access_token",
+ "/docs",
+ "/api-docs",
+ "/openapi.json",
+ "/static/*",
+ "/gen/*",
+ ],
+ ),
+ ]
+ return middleware
+
+def mount_static_and_config_swagger(app: FastAPI):
+
+ from fastapi.staticfiles import StaticFiles
+ from fastapi.openapi.docs import get_swagger_ui_html
+
+ app.mount("/static", StaticFiles(directory="static"), name="静态文件")
+
+ @app.get(app.docs_url, include_in_schema=False)
+ async def custom_swagger_ui_html():
+ print('访问文档')
+ return get_swagger_ui_html(
+ openapi_url=app.openapi_url,
+ title=app.title,
+ swagger_js_url="/static/swagger-ui/swagger-ui-bundle.js",
+ swagger_css_url="/static/swagger-ui/swagger-ui.css"
+ )
+
+def register_exceptions(app: FastAPI):
+ register_exception_handlers(app)
+ print('注册异常处理完成')
+
+
+def register_routers(app: FastAPI, prefix: str = "/api"):
+ app.include_router(api_router, prefix=prefix)
+
+
+async def init_superuser():
+ user = await user_controller.model.exists()
+ if not user:
+ await user_controller.create_user(
+ UserCreate(
+ username="admin",
+ email="admin@admin.com",
+ password="123456",
+ is_active=True,
+ is_superuser=True,
+ )
+ )
+
+
+async def init_menus():
+ menus = await Menu.exists()
+ if not menus:
+ parent_menu = await Menu.create(
+ menu_type=MenuType.CATALOG,
+ name="系统管理",
+ path="/system",
+ order=1,
+ parent_id=0,
+ icon="carbon:gui-management",
+ is_hidden=False,
+ component="Layout",
+ keepalive=False,
+ redirect="/system/user",
+ )
+ children_menu = [
+ Menu(
+ menu_type=MenuType.MENU,
+ name="用户管理",
+ path="user",
+ order=1,
+ parent_id=parent_menu.id,
+ icon="material-symbols:person-outline-rounded",
+ is_hidden=False,
+ component="/system/user",
+ keepalive=False,
+ ),
+ Menu(
+ menu_type=MenuType.MENU,
+ name="角色管理",
+ path="role",
+ order=2,
+ parent_id=parent_menu.id,
+ icon="carbon:user-role",
+ is_hidden=False,
+ component="/system/role",
+ keepalive=False,
+ ),
+ Menu(
+ menu_type=MenuType.MENU,
+ name="菜单管理",
+ path="menu",
+ order=3,
+ parent_id=parent_menu.id,
+ icon="material-symbols:list-alt-outline",
+ is_hidden=False,
+ component="/system/menu",
+ keepalive=False,
+ ),
+ Menu(
+ menu_type=MenuType.MENU,
+ name="API管理",
+ path="api",
+ order=4,
+ parent_id=parent_menu.id,
+ icon="ant-design:api-outlined",
+ is_hidden=False,
+ component="/system/api",
+ keepalive=False,
+ ),
+ Menu(
+ menu_type=MenuType.MENU,
+ name="部门管理",
+ path="dept",
+ order=5,
+ parent_id=parent_menu.id,
+ icon="mingcute:department-line",
+ is_hidden=False,
+ component="/system/dept",
+ keepalive=False,
+ ),
+ Menu(
+ menu_type=MenuType.MENU,
+ name="审计日志",
+ path="auditlog",
+ order=6,
+ parent_id=parent_menu.id,
+ icon="ph:clipboard-text-bold",
+ is_hidden=False,
+ component="/system/auditlog",
+ keepalive=False,
+ ),
+ ]
+ await Menu.bulk_create(children_menu)
+ await Menu.create(
+ menu_type=MenuType.MENU,
+ name="一级菜单",
+ path="/top-menu",
+ order=2,
+ parent_id=0,
+ icon="material-symbols:featured-play-list-outline",
+ is_hidden=False,
+ component="/top-menu",
+ keepalive=False,
+ redirect="",
+ )
+
+
+async def init_apis():
+ apis = await api_controller.model.exists()
+ if not apis:
+ await api_controller.refresh_api()
+
+
+async def init_db():
+ command = Command(tortoise_config=settings.TORTOISE_ORM)
+ try:
+ await command.init_db(safe=True)
+ except FileExistsError:
+ pass
+
+ await command.init()
+ try:
+ await command.migrate()
+ except AttributeError:
+ logger.warning("unable to retrieve model history from database, model history will be created from scratch")
+ shutil.rmtree("migrations")
+ await command.init_db(safe=True)
+
+ await command.upgrade(run_in_transaction=True)
+
+
+async def init_roles():
+ roles = await Role.exists()
+ if not roles:
+ admin_role = await Role.create(
+ name="管理员",
+ desc="管理员角色",
+ )
+ user_role = await Role.create(
+ name="普通用户",
+ desc="普通用户角色",
+ )
+
+ await Dept.create(name="默认部门")
+
+ # 分配所有API给管理员角色
+ all_apis = await Api.all()
+ await admin_role.apis.add(*all_apis)
+ # 分配所有菜单给管理员和普通用户
+ all_menus = await Menu.all()
+ await admin_role.menus.add(*all_menus)
+ await user_role.menus.add(*all_menus)
+
+ # 为普通用户分配基本API
+ basic_apis = await Api.filter(Q(method__in=["GET"]) | Q(tags="基础模块"))
+ await user_role.apis.add(*basic_apis)
+
+async def init_cache():
+ await redis_client.init_redis(settings.REDIS_URL)
+
+async def init_task():
+
+ task = await Task.exists(title="系统待办")
+ if task: return
+
+ scenario = await Scenario.create(
+ title="系统待办",
+ visible=False,
+ trigger={},
+ actions=[],
+ enabled=False,
+ notes="用于为所有存量数据创建的待办任务",
+ )
+ task = await Task.create(
+ title="系统待办",
+ status='success',
+ ui_schema={},
+ source_scenario=scenario,
+ notes="当系统有新的待办任务时关联动作",
+ )
+
+
+async def init_data():
+ await init_db()
+ await init_task()
+ await init_cache()
+ await init_superuser()
+ await init_menus()
+ await init_apis()
+ await init_roles()
+
+async def tear_down():
+ await redis_client.close()
diff --git a/app/core/middlewares.py b/app/core/middlewares.py
new file mode 100644
index 0000000..e4f2290
--- /dev/null
+++ b/app/core/middlewares.py
@@ -0,0 +1,192 @@
+import json
+import re
+from datetime import datetime
+from typing import Any, AsyncGenerator
+
+from fastapi import FastAPI
+from fastapi.responses import Response
+from fastapi.routing import APIRoute
+from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
+from starlette.requests import Request
+from starlette.types import ASGIApp, Receive, Scope, Send
+
+from app.core.dependency import AuthControl
+from app.models.admin import AuditLog, User
+
+from .bgtask import BgTasks
+
+
+class SimpleBaseMiddleware:
+ def __init__(self, app: ASGIApp) -> None:
+ self.app = app
+
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
+ if scope["type"] != "http":
+ await self.app(scope, receive, send)
+ return
+
+ request = Request(scope, receive=receive)
+
+ response = await self.before_request(request) or self.app
+ await response(request.scope, request.receive, send)
+ await self.after_request(request)
+
+ async def before_request(self, request: Request):
+ return self.app
+
+ async def after_request(self, request: Request):
+ return None
+
+
+class BackGroundTaskMiddleware(SimpleBaseMiddleware):
+ async def before_request(self, request):
+ await BgTasks.init_bg_tasks_obj()
+
+ async def after_request(self, request):
+ await BgTasks.execute_tasks()
+
+
+class HttpAuditLogMiddleware(BaseHTTPMiddleware):
+ def __init__(self, app, methods: list[str], exclude_paths: list[str]):
+ super().__init__(app)
+ self.methods = methods
+ self.exclude_paths = exclude_paths
+ self.audit_log_paths = ["/api/v1/auditlog/list"]
+ self.max_body_size = 1024 * 1024 # 1MB 响应体大小限制
+
+ async def get_request_args(self, request: Request) -> dict:
+ args = {}
+ # 获取查询参数
+ for key, value in request.query_params.items():
+ args[key] = value
+
+ # 判断是否为文件上传请求
+ content_type = request.headers.get("content-type", "").lower()
+ is_upload = (
+ request.method in {"POST", "PUT"} # 上传通常用 POST/PUT
+ and (
+ "multipart/form-data" in content_type # 标准文件上传
+ or "application/octet-stream" in content_type # 二进制流上传(较少见)
+ )
+ )
+
+ if is_upload:
+ return {}
+
+ # 获取请求体
+ if request.method in ["POST", "PUT", "PATCH"]:
+ try:
+ body = await request.json()
+ args.update(body)
+ except json.JSONDecodeError:
+ try:
+ body = await request.form()
+ args.update(body)
+ except Exception:
+ pass
+
+ return args
+
+ async def get_response_body(self, request: Request, response: Response) -> Any:
+ content_type = response.headers.get("content-type", "").lower()
+ if not content_type.startswith("application/json"):
+ return {"msg": "Non-JSON response (e.g., file download), skipped for audit"}
+
+ # 检查Content-Length
+ content_length = response.headers.get("content-length")
+ if content_length and int(content_length) > self.max_body_size:
+ return {"code": 0, "msg": "Response too large to log", "data": None}
+
+ if hasattr(response, "body"):
+ body = response.body
+ else:
+ body_chunks = []
+ async for chunk in response.body_iterator:
+ if not isinstance(chunk, bytes):
+ chunk = chunk.encode(response.charset)
+ body_chunks.append(chunk)
+
+ response.body_iterator = self._async_iter(body_chunks)
+ body = b"".join(body_chunks)
+
+ if any(request.url.path.startswith(path) for path in self.audit_log_paths):
+ try:
+ data = self.lenient_json(body)
+ # 只保留基本信息,去除详细的响应内容
+ if isinstance(data, dict):
+ data.pop("response_body", None)
+ if "data" in data and isinstance(data["data"], list):
+ for item in data["data"]:
+ item.pop("response_body", None)
+ return data
+ except Exception:
+ return None
+
+ return self.lenient_json(body)
+
+ def lenient_json(self, v: Any) -> Any:
+ if isinstance(v, (str, bytes)):
+ try:
+ return json.loads(v)
+ except (ValueError, TypeError):
+ pass
+ return v
+
+ async def _async_iter(self, items: list[bytes]) -> AsyncGenerator[bytes, None]:
+ for item in items:
+ yield item
+
+ async def get_request_log(self, request: Request, response: Response) -> dict:
+ """
+ 根据request和response对象获取对应的日志记录数据
+ """
+ data: dict = {"path": request.url.path, "status": response.status_code, "method": request.method}
+ # 路由信息
+ app: FastAPI = request.app
+ for route in app.routes:
+ if (
+ isinstance(route, APIRoute)
+ and route.path_regex.match(request.url.path)
+ and request.method in route.methods
+ ):
+ data["module"] = ",".join(route.tags)
+ data["summary"] = route.summary
+ # 获取用户信息
+ try:
+ token = request.headers.get("token")
+ user_obj = None
+ if token:
+ user_obj: User = await AuthControl.is_authed(token)
+ data["user_id"] = user_obj.id if user_obj else 0
+ data["username"] = user_obj.username if user_obj else ""
+ except Exception:
+ data["user_id"] = 0
+ data["username"] = ""
+ return data
+
+ async def before_request(self, request: Request):
+ request_args = await self.get_request_args(request)
+ request.state.request_args = request_args
+
+ async def after_request(self, request: Request, response: Response, process_time: int):
+ if request.method in self.methods:
+ for path in self.exclude_paths:
+ if re.search(path, request.url.path, re.I) is not None:
+ return
+ data: dict = await self.get_request_log(request=request, response=response)
+ data["response_time"] = process_time
+
+ data["request_args"] = request.state.request_args
+ data["response_body"] = await self.get_response_body(request, response)
+ await AuditLog.create(**data)
+
+ return response
+
+ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
+ start_time: datetime = datetime.now()
+ await self.before_request(request)
+ response = await call_next(request)
+ end_time: datetime = datetime.now()
+ process_time = int((end_time.timestamp() - start_time.timestamp()) * 1000)
+ await self.after_request(request, response, process_time)
+ return response
diff --git a/app/http_base.py b/app/http_base.py
new file mode 100644
index 0000000..d102df2
--- /dev/null
+++ b/app/http_base.py
@@ -0,0 +1,66 @@
+import inspect
+from collections import namedtuple
+from datetime import datetime
+from functools import wraps
+from typing import Callable, TypeVar
+
+import pytz
+from fastapi.encoders import jsonable_encoder
+from fastapi.responses import JSONResponse
+
+from app.settings import settings
+
+__all__ = ['HttpCode', 'HttpResp', 'unified_resp']
+
+RT = TypeVar('RT') # 返回类型
+HttpCode = namedtuple('HttpResp', ['code', 'msg'])
+
+
+class HttpResp:
+ """HTTP响应结果
+ """
+ SUCCESS = HttpCode(200, '成功')
+ FAILED = HttpCode(300, '失败')
+ PARAMS_VALID_ERROR = HttpCode(310, '参数校验错误')
+ PARAMS_TYPE_ERROR = HttpCode(311, '参数类型错误')
+ REQUEST_METHOD_ERROR = HttpCode(312, '请求方法错误')
+ ASSERT_ARGUMENT_ERROR = HttpCode(313, '断言参数错误')
+
+ LOGIN_ACCOUNT_ERROR = HttpCode(330, '登录账号或密码错误')
+ LOGIN_DISABLE_ERROR = HttpCode(331, '登录账号已被禁用了')
+ TOKEN_EMPTY = HttpCode(332, 'token参数为空')
+ TOKEN_INVALID = HttpCode(333, 'token参数无效')
+
+ NO_PERMISSION = HttpCode(403, '无相关权限')
+ REQUEST_404_ERROR = HttpCode(404, '请求接口不存在')
+
+ SYSTEM_ERROR = HttpCode(500, '系统错误')
+ SYSTEM_TIMEOUT_ERROR = HttpCode(504, '请求超时')
+
+# 时区
+timezone = pytz.timezone('Asia/Shanghai')
+
+def unified_resp(func: Callable[..., RT]) -> Callable[..., RT]:
+ """统一响应格式
+ 接口正常返回时,统一响应结果格式
+ """
+
+ @wraps(func)
+ async def wrapper(*args, **kwargs) -> RT:
+ if inspect.iscoroutinefunction(func):
+ resp = await func(*args, **kwargs) or []
+ else:
+ resp = func(*args, **kwargs) or []
+ return JSONResponse(
+ content=jsonable_encoder(
+ # 正常请求响应
+ {'code': HttpResp.SUCCESS.code, 'msg': HttpResp.SUCCESS.msg, 'data': resp},
+ by_alias=False,
+ # 自定义日期时间格式编码器
+ custom_encoder={
+ datetime: lambda dt: dt.replace(tzinfo=pytz.utc).astimezone(timezone)
+ .strftime(settings.DATETIME_FORMAT)}),
+ media_type='application/json;charset=utf-8'
+ )
+
+ return wrapper
diff --git a/app/log/__init__.py b/app/log/__init__.py
new file mode 100644
index 0000000..968bf67
--- /dev/null
+++ b/app/log/__init__.py
@@ -0,0 +1 @@
+from .log import logger as logger
diff --git a/app/log/log.py b/app/log/log.py
new file mode 100644
index 0000000..d7c9f2a
--- /dev/null
+++ b/app/log/log.py
@@ -0,0 +1,105 @@
+from app.settings import settings
+import logging
+import logging.handlers
+from pathlib import Path
+
+class Loggin:
+ def __init__(self) -> None:
+ debug = settings.DEBUG
+ if debug:
+ self.level = "DEBUG"
+ else:
+ self.level = "INFO"
+
+ def setup_logger(self):
+ """根据settings配置设置日志系统,控制台输出支持颜色"""
+ # 获取日志配置
+ log_level = self.level
+ log_format = settings.LOG_FORMAT
+
+ # 获取文件日志配置
+ file_logging_enabled = True
+ log_file_path = ''
+
+ # 创建日志目录
+ if file_logging_enabled:
+ log_file_path = Path(settings.LOGS_ROOT) / "app.log"
+ log_file_path.parent.mkdir(exist_ok=True, parents=True)
+
+ # 构建处理器列表
+ handlers = []
+
+ # 控制台处理器(支持彩色输出)
+ console_enabled = self.level == "DEBUG"
+ if console_enabled:
+ print('添加控制台日志打印')
+ import colorlog # 新增:导入colorlog库
+
+ # 定义彩色日志级别对应的颜色
+ log_colors = {
+ 'DEBUG': 'white',
+ 'INFO': 'green',
+ 'WARNING': 'yellow',
+ 'ERROR': 'red',
+ 'CRITICAL': 'bold_red',
+ }
+
+ # 创建彩色格式化器
+ console_formatter = colorlog.ColoredFormatter(
+ # 彩色格式中需要使用%(log_color)s作为颜色标记
+ "%(log_color)s" + log_format,
+ datefmt=settings.DATETIME_FORMAT,
+ log_colors=log_colors,
+ reset=True,
+ style='%'
+ )
+
+ console_handler = logging.StreamHandler()
+ console_handler.setFormatter(console_formatter)
+ handlers.append(console_handler)
+
+ # 文件处理器(如果启用)
+ if file_logging_enabled:
+ # 获取文件大小限制和备份数量
+ max_bytes = settings.LOG_FILE_MAX_SIZE
+ backup_count = settings.LOG_FILE_BACKUP_COUNT
+
+ # 文件日志使用普通格式化器(不添加颜色代码)
+ file_formatter = logging.Formatter(
+ log_format,
+ datefmt=settings.DATETIME_FORMAT
+ )
+
+ file_handler = logging.handlers.RotatingFileHandler(
+ log_file_path,
+ maxBytes=max_bytes,
+ backupCount=backup_count,
+ encoding='utf-8'
+ )
+ file_handler.setFormatter(file_formatter)
+ handlers.append(file_handler)
+
+ # 配置根日志记录器
+ logging.basicConfig(
+ level=getattr(logging, log_level.upper(), logging.INFO),
+ format=log_format,
+ handlers=handlers,
+ datefmt=settings.DATETIME_FORMAT
+ )
+
+ # 设置第三方库的日志级别,减少噪音
+ logging.getLogger("urllib3").setLevel(logging.WARNING)
+ logging.getLogger("selenium").setLevel(logging.WARNING)
+ logging.getLogger("playwright").setLevel(logging.WARNING)
+ logging.getLogger("tortoise").setLevel(logging.WARNING)
+ logging.getLogger("httpcore").setLevel(logging.WARNING)
+
+ # 记录日志系统初始化
+ logger = logging.getLogger(__name__)
+ logger.info(f"Logging configured - Level: {log_level}, File: {log_file_path if file_logging_enabled else 'Disabled'}")
+ return logger
+
+
+
+loggin = Loggin()
+logger = loggin.setup_logger()
diff --git a/app/models/__init__.py b/app/models/__init__.py
new file mode 100644
index 0000000..ff7b280
--- /dev/null
+++ b/app/models/__init__.py
@@ -0,0 +1,5 @@
+# 新增model需要在这里导入
+from .admin import *
+from .automation import *
+from .weixin import *
+from .msg import *
diff --git a/app/models/admin.py b/app/models/admin.py
new file mode 100644
index 0000000..2f40ab6
--- /dev/null
+++ b/app/models/admin.py
@@ -0,0 +1,297 @@
+from tortoise import fields
+from tortoise import fields as Fields
+
+from app.schemas.menus import MenuType
+
+from .base import BaseModel, TimestampMixin
+from .enums import MethodType
+
+
+class User(BaseModel, TimestampMixin):
+ username = fields.CharField(max_length=20, unique=True, description="用户名称", index=True)
+ alias = fields.CharField(max_length=30, null=True, description="姓名", index=True)
+ email = fields.CharField(max_length=255, unique=True, description="邮箱", index=True)
+ phone = fields.CharField(max_length=20, null=True, description="电话", index=True)
+ password = fields.CharField(max_length=128, null=True, description="密码")
+ is_active = fields.BooleanField(default=True, description="是否激活", index=True)
+ is_superuser = fields.BooleanField(default=False, description="是否为超级管理员", index=True)
+ last_login = fields.DatetimeField(null=True, description="最后登录时间", index=True)
+ roles = fields.ManyToManyField("models.Role", related_name="user_roles")
+ dept_id = fields.IntField(null=True, description="部门ID", index=True)
+
+ class Meta:
+ table = "user"
+
+
+class Role(BaseModel, TimestampMixin):
+ name = fields.CharField(max_length=20, unique=True, description="角色名称", index=True)
+ desc = fields.CharField(max_length=500, null=True, description="角色描述")
+ menus = fields.ManyToManyField("models.Menu", related_name="role_menus")
+ apis = fields.ManyToManyField("models.Api", related_name="role_apis")
+
+ class Meta:
+ table = "role"
+
+
+class Api(BaseModel, TimestampMixin):
+ path = fields.CharField(max_length=100, description="API路径", index=True)
+ method = fields.CharEnumField(MethodType, description="请求方法", index=True)
+ summary = fields.CharField(max_length=500, description="请求简介", index=True)
+ tags = fields.CharField(max_length=100, description="API标签", index=True)
+
+ class Meta:
+ table = "api"
+
+
+class Menu(BaseModel, TimestampMixin):
+ name = fields.CharField(max_length=20, description="菜单名称", index=True)
+ remark = fields.JSONField(null=True, description="保留字段")
+ menu_type = fields.CharEnumField(MenuType, null=True, description="菜单类型")
+ icon = fields.CharField(max_length=100, null=True, description="菜单图标")
+ path = fields.CharField(max_length=100, description="菜单路径", index=True)
+ order = fields.IntField(default=0, description="排序", index=True)
+ parent_id = fields.IntField(default=0, description="父菜单ID", index=True)
+ is_hidden = fields.BooleanField(default=False, description="是否隐藏")
+ component = fields.CharField(max_length=100, description="组件")
+ keepalive = fields.BooleanField(default=True, description="存活")
+ redirect = fields.CharField(max_length=100, null=True, description="重定向")
+
+ class Meta:
+ table = "menu"
+
+
+class Dept(BaseModel, TimestampMixin):
+ name = fields.CharField(max_length=20, unique=True, description="部门名称", index=True)
+ desc = fields.CharField(max_length=500, null=True, description="备注")
+ is_deleted = fields.BooleanField(default=False, description="软删除标记", index=True)
+ order = fields.IntField(default=0, description="排序", index=True)
+ parent_id = fields.IntField(default=0, max_length=10, description="父部门ID", index=True)
+
+ class Meta:
+ table = "dept"
+
+
+class DeptClosure(BaseModel, TimestampMixin):
+ ancestor = fields.IntField(description="父代", index=True)
+ descendant = fields.IntField(description="子代", index=True)
+ level = fields.IntField(default=0, description="深度", index=True)
+
+
+class AuditLog(BaseModel, TimestampMixin):
+ user_id = fields.IntField(description="用户ID", index=True)
+ username = fields.CharField(max_length=64, default="", description="用户名称", index=True)
+ module = fields.CharField(max_length=64, default="", description="功能模块", index=True)
+ summary = fields.CharField(max_length=128, default="", description="请求描述", index=True)
+ method = fields.CharField(max_length=10, default="", description="请求方法", index=True)
+ path = fields.CharField(max_length=255, default="", description="请求路径", index=True)
+ status = fields.IntField(default=-1, description="状态码", index=True)
+ response_time = fields.IntField(default=0, description="响应时间(单位ms)", index=True)
+ request_args = fields.JSONField(null=True, description="请求参数")
+ response_body = fields.JSONField(null=True, description="返回数据")
+
+
+class Codegen(BaseModel, TimestampMixin):
+ """
+ {
+ "name": "user",
+ "author": "Ly997",
+ "description": "系统用户管理",
+ "version": "1.0.0",
+ "fields": [
+ {
+ "name": "id",
+ "type": "Long",
+ "primary_key": true,
+ "description": "用户ID",
+ "required": true,
+ "editable": true,
+ "listable": true,
+ "detailable": true,
+ "sortable": true,
+ "filterable": true,
+ "filter_operator": "equal",
+ "display_type": "text"
+ },
+ {
+ "name": "username",
+ "type": "String",
+ "length": 50,
+ "description": "用户名",
+ "required": true,
+ "unique": true,
+ "validations": [
+ {
+ "type": "min_length",
+ "value": 4
+ },
+ {
+ "type": "max_length",
+ "value": 20
+ }
+ ],
+ "editable": true,
+ "listable": true,
+ "detailable": true,
+ "sortable": true,
+ "filterable": true,
+ "filter_operator": "equal",
+ "display_type": "text"
+ },
+ {
+ "name": "email",
+ "type": "String",
+ "description": "电子邮箱",
+ "required": true,
+ "validations": [
+ {
+ "type": "email"
+ }
+ ],
+ "editable": true,
+ "listable": true,
+ "detailable": true,
+ "sortable": true,
+ "filterable": true,
+ "filter_operator": "equal",
+ "display_type": "text"
+ },
+ {
+ "name": "password_hash",
+ "type": "String",
+ "description": "密码哈希",
+ "required": true,
+ "secret": true,
+ "editable": true,
+ "listable": true,
+ "detailable": true,
+ "sortable": true,
+ "filterable": true,
+ "filter_operator": "equal",
+ "display_type": "text"
+ },
+ {
+ "name": "is_active",
+ "type": "Boolean",
+ "description": "是否激活",
+ "default": true,
+ "editable": true,
+ "listable": true,
+ "detailable": true,
+ "sortable": true,
+ "filterable": true,
+ "filter_operator": "equal",
+ "display_type": "text"
+ },
+ {
+ "name": "created_at",
+ "type": "DateTime",
+ "description": "创建时间",
+ "auto_now_add": true,
+ "editable": true,
+ "listable": true,
+ "detailable": true,
+ "sortable": true,
+ "filterable": true,
+ "filter_operator": "equal",
+ "display_type": "text"
+ },
+ {
+ "name": "updated_at",
+ "type": "DateTime",
+ "description": "更新时间",
+ "auto_now": true,
+ "editable": true,
+ "listable": true,
+ "detailable": true,
+ "sortable": true,
+ "filterable": true,
+ "filter_operator": "equal",
+ "display_type": "text"
+ }
+ ],
+ "relations": [
+ {
+ "name": "roles",
+ "type": "many-to-many",
+ "target": "role",
+ "through": "user_roles",
+ "description": "用户角色关联"
+ },
+ {
+ "name": "posts",
+ "type": "one-to-many",
+ "target": "post",
+ "description": "用户发表的文章"
+ }
+ ],
+ "api": {
+ "operations": [
+ "create",
+ "read",
+ "update",
+ "delete",
+ "list"
+ ],
+ "base_path": "/users",
+ "auth_required": true,
+ "permissions": {
+ "create": [
+ "admin",
+ "manager"
+ ],
+ "delete": [
+ "admin"
+ ]
+ }
+ },
+ "menu": {
+ "label": "用户管理",
+ "icon": "user",
+ "order": 10,
+ "submenu": [
+ {
+ "label": "用户列表",
+ "path": "/users",
+ "icon": "list"
+ },
+ {
+ "label": "角色管理",
+ "path": "/roles"
+ }
+ ]
+ }
+} """
+ name = Fields.CharField(max_length=64, description="名称", index=True)
+ author = Fields.CharField(max_length=64, description="作者", index=True)
+ description = Fields.CharField(max_length=500, description="描述")
+ version = Fields.CharField(max_length=64, description="版本", index=True)
+ fields = Fields.JSONField(description="字段")
+ relations = Fields.JSONField(description="关系")
+ api = Fields.JSONField(description="API", null=True)
+ menu = Fields.JSONField(description="菜单", null=True)
+
+ class Meta:
+ table = "codegen"
+
+ async def dumps(self, output_type='pydantic'):
+ from app.schemas.codegen import BaseCodegen
+
+ obj_dict = await self.to_dict()
+ # print('type(obj_dict["fields"])', type(obj_dict["fields"]))
+ # print('type(obj_dict["relations"])', type(obj_dict["relations"]))
+ # print('type(obj_dict["api"])', type(obj_dict["api"]))
+ # obj_dict['fields'] = json.loads(obj_dict['fields'])
+ # obj_dict['relations'] = json.loads(obj_dict['relations'])
+ # obj_dict['api'] = json.loads(obj_dict['api'])
+ # obj_dict['menu'] = json.loads(obj_dict['menu'])
+
+ if output_type == 'pydantic': return BaseCodegen(**obj_dict)
+ else : return obj_dict
+
+
+class Datasource(BaseModel, TimestampMixin):
+ tableName = Fields.CharField(max_length=32, description="名称", index=True)
+ tableComment = Fields.CharField(max_length=64, description="注释", index=True)
+
+ class Meta:
+ table = "datasource"
\ No newline at end of file
diff --git a/app/models/automation.py b/app/models/automation.py
new file mode 100644
index 0000000..f1343e3
--- /dev/null
+++ b/app/models/automation.py
@@ -0,0 +1,119 @@
+from tortoise import fields
+
+from .base import BaseModel, TimestampMixin
+from .enums import TaskStatus, TaskType, ActionType, TaskStatus, ScenarioScope
+
+
+class Scenario(BaseModel, TimestampMixin):
+ title = fields.CharField(max_length=255, description="场景标题", index=True)
+ trigger = fields.JSONField(description="触发条件(事件/时间)")
+ actions = fields.JSONField(default=list, description="执行动作模板列表")
+ visible = fields.BooleanField(default=True, description="是否可见", index=True)
+ notes = fields.TextField(null=True, description="备注")
+ is_global = fields.BooleanField(default=True, description="是否全局场景", index=True)
+ owner_user_id = fields.CharField(max_length=64, null=True, description="归属用户ID(非全局时有效)", index=True)
+ enabled = fields.BooleanField(default=True, description="是否启用", index=True)
+ scope = fields.CharEnumField(ScenarioScope, default=ScenarioScope.ALL, description="作用角色范围", index=True)
+ due_days = fields.IntField(null=True, description="截止时间(天)")
+
+ class Meta:
+ table = "automation_scenario"
+ indexes = [
+ ["is_global", "enabled"],
+ ["owner_user_id", "enabled"],
+ ]
+
+"""
+CREATE TABLE automation_scenario_trigger_index (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ scenario_id BIGINT NOT NULL,
+ event_name VARCHAR(64) NOT NULL COMMENT '监听的事件名,如 group_chat_updated',
+ enabled TINYINT(1) NOT NULL DEFAULT 1,
+ is_global TINYINT(1) NOT NULL DEFAULT 1,
+ owner_user_id VARCHAR(64) DEFAULT NULL,
+ scope VARCHAR(8) NOT NULL DEFAULT 'all',
+
+ PRIMARY KEY (id),
+ KEY idx_event_enabled (event_name, enabled),
+ KEY idx_scenario_id (scenario_id),
+ CONSTRAINT fk_scenario_def FOREIGN KEY (scenario_id) REFERENCES automation_scenario(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+"""
+class ScenarioTriggerIndex(BaseModel, TimestampMixin):
+ scenario_id = fields.BigIntField(description="场景ID")
+ type = fields.CharEnumField(TaskType, default=TaskType.EVENT, description="事件类型", index=True)
+ event_name = fields.CharField(max_length=64, description="监听的事件名,如 group_chat_updated", index=True)
+ enabled = fields.BooleanField(default=True, description="是否启用", index=True)
+ is_global = fields.BooleanField(default=True, description="是否全局场景", index=True)
+ owner_user_id = fields.CharField(max_length=64, null=True, description="归属用户ID(非全局时有效)", index=True)
+ scope = fields.CharEnumField(ScenarioScope, default=ScenarioScope.ALL, description="作用角色范围")
+
+ class Meta:
+ table = "automation_scenario_trigger_index"
+
+
+class Task(BaseModel, TimestampMixin):
+ title = fields.CharField(max_length=255, description="待办标题", index=True)
+ event_data = fields.JSONField(default=dict, description="事件数据")
+ notes = fields.TextField(null=True, description="备注")
+ reason = fields.TextField(null=True, description="触发原因")
+
+ completed_at = fields.DatetimeField(null=True, description="完成时间")
+
+ # 关联字段
+ source_scenario = fields.ForeignKeyField(
+ "models.Scenario",
+ related_name="generated_tasks",
+ null=True,
+ on_delete=fields.SET_NULL,
+ description="来源场景"
+ )
+
+ related_order_id = fields.CharField(max_length=64, null=True, description="关联订单ID", index=True)
+ owner_user_id = fields.CharField(max_length=64, null=True, description="归属用户ID", index=True)
+
+ # 分配与状态
+ assignee_user_id = fields.CharField(max_length=64, null=True, description="指派人用户ID", index=True)
+ assignee_username = fields.CharField(max_length=64, null=True, description="指派人用户名", index=True)
+ status = fields.CharEnumField(TaskStatus, default=TaskStatus.PENDING, description="任务状态", index=True)
+
+ # 自动化控制
+ due_at = fields.DatetimeField(null=True, description="截止时间")
+ auto_closeable = fields.BooleanField(default=True, description="是否允许自动消除")
+ closed_by = fields.CharField(max_length=32, null=True, description="关闭方式: manual/auto/expired", index=True)
+
+ class Meta:
+ table = "automation_task"
+ indexes = [
+ ["assignee_user_id", "status"],
+ ]
+
+
+class Action(BaseModel, TimestampMixin):
+ type = fields.CharEnumField(ActionType, null=True, description="操作类型", index=True)
+ detail = fields.JSONField(default={}, description="操作详情")
+ done = fields.BooleanField(default=False, description="是否已完成", index=True)
+ done_at = fields.DatetimeField(null=True, description="完成时间")
+ result = fields.JSONField(default={}, description="操作结果")
+ userid = fields.CharField(max_length=64, null=True, description="操作用户ID", index=True)
+ username = fields.CharField(max_length=64, null=True, description="操作用户名称", index=True)
+ notes = fields.TextField(null=True, description="操作备注")
+
+ # 硬外键:必须属于一个 Task
+ task = fields.ForeignKeyField(
+ "models.Task",
+ related_name="actions",
+ null=True, # ← 允许为空
+ on_delete=fields.SET_NULL, # ← 注意:CASCADE 不能和 null=True 同时用于 SET_NULL
+ description="所属任务"
+ )
+
+ # 可选:记录来自哪个动作模板(场景中的定义)
+ action_template_id = fields.CharField(max_length=64, null=True, description="操作模板ID", index=True)
+
+ class Meta:
+ table = "automation_action"
+ indexes = [
+ ["task_id", "done"], # 优化查询未完成动作
+ ["type", "done"],
+ ]
\ No newline at end of file
diff --git a/app/models/base.py b/app/models/base.py
new file mode 100644
index 0000000..33e0b51
--- /dev/null
+++ b/app/models/base.py
@@ -0,0 +1,68 @@
+import asyncio
+from datetime import datetime
+
+from tortoise import fields, models
+
+from app.settings import settings
+
+
+class BaseModel(models.Model):
+ id = fields.BigIntField(pk=True, index=True)
+
+ async def to_dict(self, m2m: bool = False, exclude_fields: list[str] | None = None):
+ if exclude_fields is None:
+ exclude_fields = []
+
+ d = {}
+ for field in self._meta.db_fields:
+ if field not in exclude_fields:
+ value = getattr(self, field)
+ if isinstance(value, datetime):
+ value = value.strftime(settings.DATETIME_FORMAT)
+ d[field] = value
+
+ if m2m:
+ tasks = [
+ self.__fetch_m2m_field(field, exclude_fields)
+ for field in self._meta.m2m_fields
+ if field not in exclude_fields
+ ]
+ results = await asyncio.gather(*tasks)
+ for field, values in results:
+ d[field] = values
+
+ return d
+
+ async def __fetch_m2m_field(self, field, exclude_fields):
+ values = await getattr(self, field).all().values()
+ formatted_values = []
+
+ for value in values:
+ formatted_value = {}
+ for k, v in value.items():
+ if k not in exclude_fields:
+ if isinstance(v, datetime):
+ formatted_value[k] = v.strftime(settings.DATETIME_FORMAT)
+ else:
+ formatted_value[k] = v
+ formatted_values.append(formatted_value)
+
+ return field, formatted_values
+
+ @classmethod
+ def get_all_keys(cls, exclude_fields: list[str] | None = None):
+ if exclude_fields is None:
+ exclude_fields = []
+ return [field for field in cls._meta.fields_map.keys() if field not in exclude_fields]
+
+ class Meta:
+ abstract = True
+
+
+class UUIDModel:
+ uuid = fields.UUIDField(unique=True, pk=False, index=True)
+
+
+class TimestampMixin:
+ created_at = fields.DatetimeField(auto_now_add=True, index=True)
+ updated_at = fields.DatetimeField(auto_now=True, index=True)
diff --git a/app/models/enums.py b/app/models/enums.py
new file mode 100644
index 0000000..962437e
--- /dev/null
+++ b/app/models/enums.py
@@ -0,0 +1,64 @@
+from enum import Enum, StrEnum
+
+
+class EnumBase(Enum):
+ @classmethod
+ def get_member_values(cls):
+ return [item.value for item in cls._member_map_.values()]
+
+ @classmethod
+ def get_member_names(cls):
+ return [name for name in cls._member_names_]
+
+
+class MethodType(StrEnum):
+ GET = "GET"
+ POST = "POST"
+ PUT = "PUT"
+ DELETE = "DELETE"
+ PATCH = "PATCH"
+
+class TaskStatus(Enum):
+ PENDING = "pending"
+ RUNNING = "running"
+ SUCCESS = "success"
+ FAILED = "failed"
+ CANCELLED = "cancelled"
+ EXPIRED = "expired"
+
+class TaskType(Enum):
+ DECODE_ORDER = "decode_order"
+ EVENT = "event"
+ TIMER = "timer"
+
+class ScenarioScope(str, Enum):
+ ALL = "all"
+ PERSONAL = "personal"
+ KEFU = "kefu" # 接待员
+ FOLLOW = "follow" # 跟单员
+ DESIGNER = "designer" # 设计师
+
+class RoleType(StrEnum):
+ BUYER = "buyer"
+ DESIGER = "desiger"
+ FOLLOWER = "follower"
+ ADMIN = "admin"
+ KEFU = "taobao_kefu"
+
+class MsgType(StrEnum):
+ NEW_ORDER = "new_order"
+ COMMENT = "comment"
+ SYSTEM = "system"
+ NOTIFY = "notify"
+
+class ActionType(StrEnum):
+ CREATE_GROUP = "create_group"
+ VIEW_ERP_LOG = "view_erp_log"
+ BIND_USER = "bind_user"
+ BIND_ORDER = "bind_order"
+ WRITE_REMARK = "write_remark"
+ SEND_WECHAT_NOTIFY = "send_notify"
+ # 清理群聊
+ CLEAN_GROUP = "clean_group"
+ # 设置群管理员
+ SET_GROUP_ADMIN = "set_group_admin"
diff --git a/app/models/msg.py b/app/models/msg.py
new file mode 100644
index 0000000..953823f
--- /dev/null
+++ b/app/models/msg.py
@@ -0,0 +1,72 @@
+from tortoise import fields
+
+from .base import BaseModel, TimestampMixin
+from .enums import MsgType, ActionType
+
+class Msg(BaseModel, TimestampMixin):
+ hash_id = fields.CharField(max_length=64, null=True, description="消息哈希ID", index=True, unique=True)
+ title = fields.CharField(max_length=64, null=True, description="消息标题", index=True)
+ content = fields.TextField(null=True, description="消息内容")
+ detail = fields.JSONField(default={}, description="消息详情")
+ is_send = fields.BooleanField(default=False, description="是否已发送", index=True)
+ send_at = fields.DatetimeField(null=True, description="发送时间")
+ is_read = fields.BooleanField(default=False, description="是否已读", index=True)
+ read_at = fields.DatetimeField(null=True, description="已读时间")
+ is_delete = fields.BooleanField(default=False, description="是否已删除", index=True)
+ delete_at = fields.DatetimeField(null=True, description="删除时间")
+ type = fields.CharEnumField(MsgType, null=True, description="消息类型", index=True)
+ owner_id = fields.CharField(max_length=64, null=True, description="消息所有者ID", index=True)
+ owner_name = fields.CharField(max_length=64, null=True, description="消息所有者名称", index=True)
+
+ class Meta:
+ table = "system_msg"
+
+
+class Follow(BaseModel, TimestampMixin):
+ order_id = fields.CharField(max_length=64, null=True, description="订单ID", index=True)
+ order_title = fields.TextField(null=True, description="订单标题")
+ order_pic = fields.CharField(max_length=256, null=True, description="订单图片")
+ pay_time = fields.DatetimeField(null=True, description="支付时间", index=True)
+ pay_price = fields.FloatField(null=True, description="支付金额", index=True)
+ remark = fields.TextField(null=True, description="erp额外备注")
+
+ customer_id = fields.CharField(max_length=64, null=True, description="客户ID", index=True)
+ customer_name = fields.CharField(max_length=64, null=True, description="客户名称", index=True)
+ customer_taobao_id = fields.CharField(max_length=64, null=True, description="客户淘宝ID", index=True)
+
+ staff_id = fields.CharField(max_length=64, null=True, description="企微员工ID", index=True)
+ staff_name = fields.CharField(max_length=64, null=True, description="企微员工名称", index=True)
+ staff_time = fields.DatetimeField(null=True, description="添加时间", index=True)
+
+ designer_id = fields.CharField(max_length=64, null=True, description="设计师ID", index=True)
+ designer_name = fields.CharField(max_length=64, null=True, description="设计师名称", index=True)
+
+ shop_name = fields.CharField(max_length=64, null=True, description="店铺名称", index=True)
+ kefu_id = fields.CharField(max_length=64, null=True, description="淘宝客服ID", index=True)
+ kefu_name = fields.CharField(max_length=64, null=True, description="淘宝客服名称", index=True)
+
+ is_delete = fields.BooleanField(default=False, description="是否已删除", index=True)
+
+ feishu_record_id = fields.CharField(max_length=64, null=True, description="飞书记录ID", index=True)
+ is_add_to_feishu = fields.BooleanField(default=False, description="是否已添加到飞书", index=True)
+ is_update_to_feishu = fields.BooleanField(default=False, description="是否已更新到飞书", index=True)
+ is_delete_from_feishu = fields.BooleanField(default=False, description="是否已从飞书删除", index=True)
+
+ class Meta:
+ table = "system_follow"
+
+ def to_feishu_record(self, fields: dict=None):
+ fields = fields or {}
+ return {
+ "record_id": self.feishu_record_id,
+ "fields": {
+ "付款时间": self.pay_time.strftime("%Y-%m-%d %H:%M:%S"),
+ "店铺名": self.shop_name,
+ "成交客服": self.kefu_name,
+ "旺旺id": self.customer_name,
+ "添加企微客服": self.staff_name,
+ "订单编号": self.order_id,
+ "设计师": self.designer_name,
+ **fields
+ }
+ }
diff --git a/app/models/weixin.py b/app/models/weixin.py
new file mode 100644
index 0000000..82a6b9e
--- /dev/null
+++ b/app/models/weixin.py
@@ -0,0 +1,260 @@
+from tortoise import fields
+
+from .base import BaseModel, TimestampMixin
+from .enums import RoleType
+
+class WeixinUser(BaseModel, TimestampMixin):
+ # 自动成为订单关联方
+ auto_relate = fields.BooleanField(default=False, description="是否自动成为订单关联方")
+ userid = fields.CharField(max_length=64, description="用户微信id", index=True) # acctid
+ corp_id = fields.CharField(max_length=64, description="企业微信id", default="1970325009092879", index=True)
+ vid = fields.CharField(max_length=64, description="用户微信id", default='', index=True)
+ wx_id_hash = fields.CharField(max_length=64, null=True, description="微信id hash")
+ is_quit = fields.BooleanField(default=False, description="是否离职")
+ has_external_user_permit = fields.BooleanField(default=False, description="是否具有对外联系权限")
+ wx_nick_name = fields.CharField(max_length=64, null=True, description="微信昵称")
+ account = fields.CharField(max_length=64, null=True, description="账号")
+
+ position = fields.CharField(max_length=64, null=True, description="职位")
+ role = fields.CharEnumField(RoleType, null=True, description="用户角色", index=True)
+ depart_ids = fields.JSONField(default=[], description="部门ID列表")
+
+ username = fields.CharField(max_length=64, null=True, description="用户微信名称", index=True)
+ english_name = fields.CharField(max_length=64, null=True, description="英文名")
+ name = fields.CharField(max_length=64, null=True, description="用户名称", index=True)
+ realname = fields.CharField(max_length=64, null=True, description="用户真实名字", index=True)
+ alias = fields.CharField(max_length=30, null=True, description="别名")
+ avatar = fields.CharField(max_length=512, null=True, description="头像")
+ mobile = fields.CharField(max_length=11, null=True, description="手机号")
+ email = fields.CharField(max_length=64, null=True, description="邮箱")
+ gender = fields.IntField(null=True, description="性别")
+
+ erp_id = fields.IntField(null=True, description="ERP中的用户ID", index=True)
+ erp_name = fields.CharField(max_length=64, null=True, description="ERP中的用户名称")
+ crm_id = fields.IntField(null=True, description="星云有客中的用户ID", index=True)
+ crm_name = fields.CharField(max_length=64, null=True, description="星云有客中的用户名称")
+
+ def to_dict(self, exclude_fields=None, include_sensitive=False):
+ """
+ 自定义字典转换行为
+
+ Args:
+ exclude_fields: 要排除的字段列表
+ include_sensitive: 是否包含敏感信息(如手机号、邮箱等)
+
+ Returns:
+ dict: 转换后的字典
+ """
+ exclude_fields = exclude_fields or ['staff_memberships']
+ data = {}
+
+ # 获取所有字段名
+ model_fields = self._meta.fields_map.keys()
+
+ for field_name in model_fields:
+ if field_name in exclude_fields:
+ continue
+
+ # 如果不包含敏感信息,则跳过敏感字段
+ if not include_sensitive and field_name in ['mobile', 'email']:
+ continue
+
+ value = getattr(self, field_name)
+
+ # 处理枚举字段
+ if field_name == 'role' and value is not None:
+ data[field_name] = value.value if hasattr(value, 'value') else value
+ # 处理布尔字段的默认值显示
+ elif field_name == 'is_quit':
+ data[field_name] = bool(value) if value is not None else False
+ elif field_name == 'has_external_user_permit':
+ data[field_name] = bool(value) if value is not None else False
+ else:
+ data[field_name] = value
+
+ return data
+
+ def to_public_dict(self):
+ """
+ 返回公开信息的字典(不包含敏感信息)
+ """
+ sensitive_fields = ['mobile', 'email']
+ exclude_fields = ['wx_id_hash'] # 可能还有其他不想暴露的字段
+ all_exclude = sensitive_fields + exclude_fields
+
+ return self.to_dict(exclude_fields=all_exclude, include_sensitive=False)
+
+ def to_detail_dict(self):
+ """
+ 返回详细信息的字典(包含所有信息)
+ """
+ return self.to_dict(include_sensitive=True)
+
+ def to_safe_dict(self, visible_fields=None):
+ """
+ 返回指定字段的安全字典
+
+ Args:
+ visible_fields: 指定要包含的字段列表,如果为None则使用默认安全字段
+ """
+ if visible_fields is None:
+ # 默认的安全字段(不包含敏感信息)
+ visible_fields = [
+ 'userid', 'vid', 'wx_nick_name', 'username', 'name',
+ 'realname', 'alias', 'avatar', 'position', 'role',
+ 'depart_ids', 'erpid', 'crmid', 'created_at', 'updated_at'
+ ]
+
+ data = {}
+ for field_name in visible_fields:
+ if hasattr(self, field_name):
+ value = getattr(self, field_name)
+ if field_name == 'role' and value is not None:
+ data[field_name] = value.value if hasattr(value, 'value') else value
+ else:
+ data[field_name] = value
+
+ return data
+
+ class Meta:
+ table = "weixin_user"
+
+class WeixinGroupChat(BaseModel, TimestampMixin):
+ chat_id = fields.CharField(max_length=64, description="群聊ID", index=True)
+ xingyun_chat_id = fields.IntField(null=True, description="星云中的群聊ID", index=True)
+ name = fields.CharField(max_length=64, null=True, description="群聊名称")
+ create_time = fields.IntField(description="创建时间")
+ admin_list = fields.JSONField(default=[], description="所有管理员")
+ owner = fields.CharField(max_length=64, null=True, description="群主")
+ member_version = fields.CharField(max_length=64, null=True, description="群成员版本")
+ external_user_count = fields.IntField(null=True, description="外部群成员数量")
+ external_member_list = fields.JSONField(default=[], description="外部群成员列表")
+ internal_member_count = fields.IntField(null=True, description="内部群成员数量")
+ internal_member_list = fields.JSONField(default=[], description="内部群成员列表")
+ avatars = fields.JSONField(default=[], description="群成员头像")
+
+ class Meta:
+ table = "weixin_group_chat"
+
+class WeixinCustomer(BaseModel, TimestampMixin):
+ order_id = fields.CharField(max_length=64, null=True, description="订单ID", index=True)
+ shop_name = fields.CharField(max_length=64, null=True, description="店铺名称")
+ weixin_id = fields.CharField(max_length=64, null=True, description="微信体系中的id", index=True)
+ weixin_name = fields.CharField(max_length=64, null=True, description="微信体系中的用户名")
+ weixin_unionid = fields.CharField(max_length=64, null=True, description="微信体系中的unionid", index=True)
+ weixin_avatar = fields.CharField(max_length=512, null=True, description="微信头像")
+ xingyun_id = fields.IntField(null=True, description="星云有客中的用户ID", index=True)
+ xingyun_sex = fields.IntField(null=True, description="星云有客中的用户性别", index=True)
+ xingyun_name = fields.CharField(max_length=64, null=True, description="星云有客中的用户名")
+ xingyun_avatar = fields.CharField(max_length=512, null=True, description="星云有客头像")
+ xingyun_tags = fields.JSONField(default=[], null=True, description="星云用户标签")
+ xingyun_external_userid = fields.CharField(max_length=64, null=True, description="星云有客中的外部用户ID")
+ xingyun_sync = fields.BooleanField(default=False, description="是否已同步到星云", index=True)
+ # erp_id = fields.IntField(null=True, description="ERP中的客户ID", index=True)
+ # erp_name = fields.CharField(max_length=64, null=True, description="ERP中的客户名称")
+ # erp_avatar = fields.CharField(max_length=512, null=True, description="ERP头像")
+ taobao_id = fields.CharField(max_length=64, null=True, description="淘宝中的用户ID", index=True)
+ taobao_name = fields.CharField(max_length=64, null=True, description="淘宝中的用户名")
+ taobao_avatar = fields.CharField(max_length=512, null=True, description="淘宝头像")
+ need_confirm = fields.BooleanField(null=True, description="是否需要确认")
+ extra = fields.JSONField(default={}, description="额外信息")
+
+ # 使用 through 指向自定义中间模型
+ groups = fields.ManyToManyField(
+ "models.WeixinGroupChat",
+ through="customer_group", # 必须是中间模型的 table 名(或模型名)
+ related_name="customers"
+ )
+
+ @classmethod
+ def orm_format(cls, data: dict):
+ # 将不在字段中的字段保存到extra字段中
+ all_fields = set(cls._meta.fields_map.keys())
+
+ extra = {}
+ data_in = {"extra": extra}
+ for k, v in data.copy().items():
+ if k not in all_fields:
+ extra[k] = v
+ else:
+ data_in[k] = v
+
+ id = data_in.pop('id', None)
+ if id: data_in['platform_id'] = id
+ return data_in
+
+ def to_dict(self, *args, **kwargs):
+ # 类似于WeixinUser参数
+ return {
+ "id": self.id,
+ "userid": self.weixin_id,
+ "name": self.taobao_name,
+ "avatar": self.xingyun_avatar,
+ "weixin_name": self.weixin_name,
+ "role": 'buyer',
+ "is_customer": True,
+ }
+ # data = await super().to_dict(*args, **kwargs)
+ # data['id'] = data.pop('platform_id', None)
+ # data.update(data.pop('extra', {}))
+ # return data
+
+ def dump_dict(self):
+ all_fields = set(self._meta.fields_map.keys())
+ data = {}
+ for k, v in self.__dict__.items():
+ if k in all_fields:
+ data[k] = v
+ return data
+
+ @classmethod
+ def create_bind(cls, data: dict):
+ # 将不在字段中的字段保存到extra字段中
+ data_in = cls.orm_format(data)
+ return cls(**data_in)
+
+ class Meta:
+ table = "weixin_customer"
+
+# === 中间模型:CustomerGroup ===
+class CustomerGroup(BaseModel, TimestampMixin):
+ """顾客与群聊的关联关系(带额外信息)"""
+
+ # 外键指向顾客
+ customer = fields.ForeignKeyField(
+ "models.WeixinCustomer",
+ related_name="group_memberships" # 从 Customer 反向查关系
+ )
+
+ # 外键指向群聊
+ group = fields.ForeignKeyField(
+ "models.WeixinGroupChat",
+ related_name="customer_memberships" # 从 Group 反向查关系
+ )
+
+ # 外键指向群聊
+ staff = fields.ForeignKeyField(
+ "models.WeixinUser",
+ related_name="staff_memberships" # 从 User 反向查关系
+ )
+
+ # 额外字段
+ join_time = fields.DatetimeField(auto_now_add=True, description="入群时间", index=True)
+ role = fields.CharField(
+ max_length=32,
+ default="member",
+ description="群内角色:member / admin / owner",
+ index=True
+ )
+
+ staff_userid = fields.CharField(max_length=64, null=True, description="客服ID", index=True)
+ customer_userid = fields.CharField(max_length=64, null=True, description="顾客的ID", index=True)
+ group_chatid = fields.CharField(max_length=64, null=True, description="群聊ID", index=True)
+ order_id = fields.CharField(max_length=64, null=True, description="订单ID", index=True)
+ shop_name = fields.CharField(max_length=64, null=True, description="店铺名称", index=True)
+ remark = fields.CharField(max_length=255, null=True, description="备注")
+
+ class Meta:
+ table = "customer_group"
+ # 确保同一个顾客不能重复加入同一个群
+ unique_together = ("customer", "group")
\ No newline at end of file
diff --git a/app/resources/.gitignore b/app/resources/.gitignore
new file mode 100644
index 0000000..ad414b4
--- /dev/null
+++ b/app/resources/.gitignore
@@ -0,0 +1,13 @@
+
+train/
+# 忽略 models 目录下的所有文件和子目录
+models/*
+
+# 递归保留所有子目录中的 best_model 目录
+models/订单尺寸识别/*
+!models/订单尺寸识别/
+!models/订单尺寸识别/best_model/
+
+models/定制印刷品-订单描述/*
+!models/定制印刷品-订单描述/
+!models/定制印刷品-订单描述/best_model/
diff --git a/app/resources/20260616154526_多淘印6.16_解析_横向排列_1.xlsx b/app/resources/20260616154526_多淘印6.16_解析_横向排列_1.xlsx
new file mode 100644
index 0000000000000000000000000000000000000000..5e0e20acf3049d61face34c598acb46a5a92ddb1
GIT binary patch
literal 432164
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