first commit

This commit is contained in:
2026-06-25 17:41:06 +08:00
commit b2f23a933b
370 changed files with 30526 additions and 0 deletions
+46
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
*.html linguist-language=python
+35
View File
@@ -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.*
+63
View File
@@ -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"]
+21
View File
@@ -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.
+87
View File
@@ -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
+219
View File
@@ -0,0 +1,219 @@
<p align="center">
<a href="https://github.com/mizhexiaoxiao/vue-fastapi-admin">
<img alt="Vue FastAPI Admin Logo" width="200" src="https://github.com/mizhexiaoxiao/vue-fastapi-admin/blob/main/deploy/sample-picture/logo.svg">
</a>
</p>
<h1 align="center">vue-fastapi-admin</h1>
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
![Login Page](https://github.com/mizhexiaoxiao/vue-fastapi-admin/blob/main/deploy/sample-picture/login.jpg)
#### Workbench
![Workbench](https://github.com/mizhexiaoxiao/vue-fastapi-admin/blob/main/deploy/sample-picture/workbench.jpg)
#### User Management
![User Management](https://github.com/mizhexiaoxiao/vue-fastapi-admin/blob/main/deploy/sample-picture/user.jpg)
#### Role Management
![Role Management](https://github.com/mizhexiaoxiao/vue-fastapi-admin/blob/main/deploy/sample-picture/role.jpg)
#### Menu Management
![Menu Management](https://github.com/mizhexiaoxiao/vue-fastapi-admin/blob/main/deploy/sample-picture/menu.jpg)
#### API Management
![API Management](https://github.com/mizhexiaoxiao/vue-fastapi-admin/blob/main/deploy/sample-picture/api.jpg)
### Quick Start
Please follow the instructions below for installation and configuration:
#### Method 1dockerhub 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
usernameadmin
password123456
### 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
<img align="left" src = "https://profile-counter.glitch.me/vue-fastapi-admin/count.svg" alt="Loading">
+244
View File
@@ -0,0 +1,244 @@
<p align="center">
<a href="https://gitee.com/mizhexiaoxiao/vue-fastapi-admin">
<img alt="Vue FastAPI Admin Logo" width="200" src="https://gitee.com/mizhexiaoxiao/vue-fastapi-admin/raw/main/deploy/sample-picture/logo.svg">
</a>
</p>
<h1 align="center">vue-fastapi-admin</h1>
[English](./README-en.md) | 简体中文
基于 FastAPI + Vue3 + Naive UI 的现代化前后端分离开发平台,融合了 RBAC 权限管理、动态路由和 JWT 鉴权,助力中小型应用快速搭建,也可用于学习参考。
### 特性
- **最流行技术栈**:基于 Python 3.11 和 FastAPI 高性能异步框架,结合 Vue3 和 Vite 等前沿技术进行开发,同时使用高效的 npm 包管理器 pnpm。
- **代码规范**:项目内置丰富的规范插件,确保代码质量和一致性,有效提高团队协作效率。
- **动态路由**:后端动态路由,结合 RBACRole-Based Access Control)权限模型,提供精细的菜单路由控制。
- **JWT鉴权**:使用 JSON Web Token(JWT)进行身份验证和授权,增强应用的安全性。
- **细粒度权限控制**:实现按钮和接口级别的权限控制,确保不同用户或角色在界面操作和接口访问时具有不同的权限限制。
### 在线预览
- http://47.111.145.81:3000
- username: admin
- password: 123456
### 登录页
![image](https://gitee.com/mizhexiaoxiao/vue-fastapi-admin/raw/main/deploy/sample-picture/login.jpg)
### 工作台
![image](https://gitee.com/mizhexiaoxiao/vue-fastapi-admin/raw/main/deploy/sample-picture/workbench.jpg)
### 用户管理
![image](https://gitee.com/mizhexiaoxiao/vue-fastapi-admin/raw/main/deploy/sample-picture/user.jpg)
### 角色管理
![image](https://gitee.com/mizhexiaoxiao/vue-fastapi-admin/raw/main/deploy/sample-picture/role.jpg)
### 菜单管理
![image](https://gitee.com/mizhexiaoxiao/vue-fastapi-admin/raw/main/deploy/sample-picture/menu.jpg)
### API管理
![image](https://gitee.com/mizhexiaoxiao/vue-fastapi-admin/raw/main/deploy/sample-picture/api.jpg)
### 快速开始
#### 方法一: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
usernameadmin
password123456
### 本地启动
#### 后端
启动项目需要以下环境:
- 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是作者维护下去的动力。
你可以在群里提出任何疑问,我会尽快回复答疑。
<img width="300" src="https://gitee.com/mizhexiaoxiao/vue-fastapi-admin/raw/main/deploy/sample-picture/group.jpg">
## 打赏
如果项目有帮助到你,可以请作者喝杯咖啡~
<div style="display: flex">
<img src="https://gitee.com/mizhexiaoxiao/vue-fastapi-admin/raw/main/deploy/sample-picture/1.jpg" width="300">
<img src="https://gitee.com/mizhexiaoxiao/vue-fastapi-admin/raw/main/deploy/sample-picture/2.jpg" width="300">
</div>
## 定制开发
如果有基于该项目的定制需求或其他合作,请添加下方微信,备注来意
<img width="300" src="https://gitee.com/mizhexiaoxiao/vue-fastapi-admin/raw/main/deploy/sample-picture/3.jpg">
### Visitors Count
<img align="left" src = "https://profile-counter.glitch.me/vue-fastapi-admin/count.svg" alt="Loading">
+49
View File
@@ -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()
+9
View File
@@ -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"]
+29
View File
@@ -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])
+8
View File
@@ -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"]
+67
View File
@@ -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")
+8
View File
@@ -0,0 +1,8 @@
from fastapi import APIRouter
from .auditlog import router
auditlog_router = APIRouter()
auditlog_router.include_router(router, tags=["审计日志模块"])
__all__ = ["auditlog_router"]
+44
View File
@@ -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)
+8
View File
@@ -0,0 +1,8 @@
from fastapi import APIRouter
from .automation import router
automation_router = APIRouter()
automation_router.include_router(router, tags=["自动化模块"])
__all__ = ["automation_router"]
+188
View File
@@ -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()
+8
View File
@@ -0,0 +1,8 @@
from fastapi import APIRouter
from .base import router
base_router = APIRouter()
base_router.include_router(router, tags=["基础模块"])
__all__ = ["base_router"]
+103
View File
@@ -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="修改成功")
+10
View File
@@ -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"]
+73
View File
@@ -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")
+81
View File
@@ -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")
+8
View File
@@ -0,0 +1,8 @@
from fastapi import APIRouter
from .depts import router
depts_router = APIRouter()
depts_router.include_router(router, tags=["部门模块"])
__all__ = ["depts_router"]
+48
View File
@@ -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")
+8
View File
@@ -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"]
+95
View File
@@ -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)
+8
View File
@@ -0,0 +1,8 @@
from fastapi import APIRouter
from .menus import router
menus_router = APIRouter()
menus_router.include_router(router, tags=["菜单模块"])
__all__ = ["menus_router"]
+63
View File
@@ -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")
+9
View File
@@ -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"]
+15
View File
@@ -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}
+80
View File
@@ -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)
+8
View File
@@ -0,0 +1,8 @@
from fastapi import APIRouter
from .roles import router
roles_router = APIRouter()
roles_router.include_router(router, tags=["角色模块"])
__all__ = ["roles_router"]
+73
View File
@@ -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")
+8
View File
@@ -0,0 +1,8 @@
from fastapi import APIRouter
from .users import router
users_router = APIRouter()
users_router.include_router(router, tags=["用户模块"])
__all__ = ["users_router"]
+87
View File
@@ -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")
+11
View File
@@ -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"]
+16
View File
@@ -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]
+2
View File
@@ -0,0 +1,2 @@
httpx
pypinyin
+95
View File
@@ -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)
+294
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
from .role import role_controller as role_controller
from .user import user_controller as user_controller
+109
View File
@@ -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()
+46
View File
@@ -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()
+82
View File
@@ -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()
+222
View File
@@ -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()
+77
View File
@@ -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()
+235
View File
@@ -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()
+41
View File
@@ -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()
+86
View File
@@ -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()
+43
View File
@@ -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()
@@ -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)
@@ -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
+16
View File
@@ -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()
+461
View File
@@ -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()
+27
View File
@@ -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()
+62
View File
@@ -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()
+60
View File
@@ -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()
+607
View File
@@ -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()
+408
View File
@@ -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()
+169
View File
@@ -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()
+201
View File
@@ -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()
+4
View File
@@ -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
+20
View File
@@ -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')
+66
View File
@@ -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客户服务群@喜印说", "宇宙大女神")
+31
View File
@@ -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()
+163
View File
@@ -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 返回 bytesstdlib 返回 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
+78
View File
@@ -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()
+17
View File
@@ -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(),
}
+69
View File
@@ -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)
+150
View File
@@ -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) # 全局异常处理器
+273
View File
@@ -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()
+192
View File
@@ -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
+66
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
from .log import logger as logger
+105
View File
@@ -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()
+5
View File
@@ -0,0 +1,5 @@
# 新增model需要在这里导入
from .admin import *
from .automation import *
from .weixin import *
from .msg import *
+297
View File
@@ -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"
+119
View File
@@ -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"],
]
+68
View File
@@ -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)
+64
View File
@@ -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"
+72
View File
@@ -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
}
}
+260
View File
@@ -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")
+13
View File
@@ -0,0 +1,13 @@
train/
# 忽略 models 目录下的所有文件和子目录
models/*
# 递归保留所有子目录中的 best_model 目录
models/订单尺寸识别/*
!models/订单尺寸识别/
!models/订单尺寸识别/best_model/
models/定制印刷品-订单描述/*
!models/定制印刷品-订单描述/
!models/定制印刷品-订单描述/best_model/
@@ -0,0 +1,135 @@
[paths]
train = null
dev = null
vectors = null
init_tok2vec = null
[system]
seed = 0
gpu_allocator = null
[nlp]
lang = "zh"
pipeline = ["ner"]
disabled = []
before_creation = null
after_creation = null
after_pipeline_creation = null
batch_size = 1000
vectors = {"@vectors":"spacy.Vectors.v1"}
[nlp.tokenizer]
@tokenizers = "spacy.zh.ChineseTokenizer"
segmenter = "char"
[components]
[components.ner]
factory = "ner"
incorrect_spans_key = null
moves = null
scorer = {"@scorers":"spacy.ner_scorer.v1"}
update_with_oracle_cut_size = 100
[components.ner.model]
@architectures = "spacy.TransitionBasedParser.v2"
state_type = "ner"
extra_state_tokens = false
hidden_width = 64
maxout_pieces = 2
use_upper = true
nO = null
[components.ner.model.tok2vec]
@architectures = "spacy.HashEmbedCNN.v2"
pretrained_vectors = null
width = 96
depth = 4
embed_size = 2000
window_size = 1
maxout_pieces = 3
subword_features = true
[corpora]
[corpora.dev]
@readers = "spacy.Corpus.v1"
path = ${paths.dev}
gold_preproc = false
max_length = 0
limit = 0
augmenter = null
[corpora.train]
@readers = "spacy.Corpus.v1"
path = ${paths.train}
gold_preproc = false
max_length = 0
limit = 0
augmenter = null
[training]
seed = ${system.seed}
gpu_allocator = ${system.gpu_allocator}
dropout = 0.1
accumulate_gradient = 1
patience = 1600
max_epochs = 0
max_steps = 20000
eval_frequency = 200
frozen_components = []
annotating_components = []
dev_corpus = "corpora.dev"
train_corpus = "corpora.train"
before_to_disk = null
before_update = null
[training.batcher]
@batchers = "spacy.batch_by_words.v1"
discard_oversize = false
tolerance = 0.2
get_length = null
[training.batcher.size]
@schedules = "compounding.v1"
start = 100
stop = 1000
compound = 1.001
t = 0.0
[training.logger]
@loggers = "spacy.ConsoleLogger.v1"
progress_bar = false
[training.optimizer]
@optimizers = "Adam.v1"
beta1 = 0.9
beta2 = 0.999
L2_is_weight_decay = true
L2 = 0.01
grad_clip = 1.0
use_averages = false
eps = 0.00000001
learn_rate = 0.001
[training.score_weights]
ents_f = 1.0
ents_p = 0.0
ents_r = 0.0
ents_per_type = null
[pretraining]
[initialize]
vectors = ${paths.vectors}
init_tok2vec = ${paths.init_tok2vec}
vocab_data = null
lookups = null
before_init = null
after_init = null
[initialize.components]
[initialize.tokenizer]
pkuseg_model = null
pkuseg_user_dict = "default"
@@ -0,0 +1,41 @@
{
"lang":"zh",
"name":"pipeline",
"version":"0.0.0",
"spacy_version":">=3.8.7,<3.9.0",
"description":"",
"author":"",
"email":"",
"url":"",
"license":"",
"spacy_git_version":"4b65aa7",
"vectors":{
"width":0,
"vectors":0,
"keys":0,
"name":null,
"mode":"default"
},
"labels":{
"ner":[
"\u4ea7\u54c1",
"\u522e\u522e\u819c\u5c3a\u5bf8",
"\u52a0\u6025",
"\u5355\u53f7",
"\u5c3a\u5bf8",
"\u5de5\u827a",
"\u6570\u91cf",
"\u6750\u8d28",
"\u7528\u6237\u4fe1\u606f"
]
},
"pipeline":[
"ner"
],
"components":[
"ner"
],
"disabled":[
]
}
@@ -0,0 +1,13 @@
{
"moves":null,
"update_with_oracle_cut_size":100,
"multitasks":[
],
"min_action_freq":1,
"learn_tokens":false,
"beam_width":1,
"beam_density":0.0,
"beam_update_prob":0.0,
"incorrect_spans_key":null
}
@@ -0,0 +1 @@
‚¥movesÚ0{"0":{},"1":{"\u52a0\u6025":-1,"\u7528\u6237\u4fe1\u606f":-2,"\u5355\u53f7":-3,"\u6750\u8d28":-4,"\u522e\u522e\u819c\u5c3a\u5bf8":-5,"\u5de5\u827a":-6,"\u6570\u91cf":-7,"\u5c3a\u5bf8":-8,"\u4ea7\u54c1":-9},"2":{"\u52a0\u6025":-1,"\u7528\u6237\u4fe1\u606f":-2,"\u5355\u53f7":-3,"\u6750\u8d28":-4,"\u522e\u522e\u819c\u5c3a\u5bf8":-5,"\u5de5\u827a":-6,"\u6570\u91cf":-7,"\u5c3a\u5bf8":-8,"\u4ea7\u54c1":-9},"3":{"\u52a0\u6025":-1,"\u7528\u6237\u4fe1\u606f":-2,"\u5355\u53f7":-3,"\u6750\u8d28":-4,"\u522e\u522e\u819c\u5c3a\u5bf8":-5,"\u5de5\u827a":-6,"\u6570\u91cf":-7,"\u5c3a\u5bf8":-8,"\u4ea7\u54c1":-9},"4":{"":1,"\u52a0\u6025":-1,"\u7528\u6237\u4fe1\u606f":-2,"\u5355\u53f7":-3,"\u6750\u8d28":-4,"\u522e\u522e\u819c\u5c3a\u5bf8":-5,"\u5de5\u827a":-6,"\u6570\u91cf":-7,"\u5c3a\u5bf8":-8,"\u4ea7\u54c1":-9},"5":{"":1}}£cfg§neg_keyÀ
@@ -0,0 +1,3 @@
{
"segmenter":"char"
}
@@ -0,0 +1,675 @@
[
" ",
"(",
")",
"+",
",",
"-",
".",
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
":",
";",
"A",
"B",
"C",
"D",
"E",
"F",
"G",
"H",
"J",
"K",
"L",
"M",
"N",
"P",
"Q",
"R",
"ROOT",
"S",
"T",
"U",
"V",
"Y",
"Z",
"[",
"]",
"a",
"b",
"c",
"d",
"e",
"f",
"g",
"h",
"i",
"j",
"k",
"l",
"m",
"n",
"o",
"p",
"q",
"r",
"s",
"t",
"u",
"v",
"w",
"x",
"y",
"z",
"zh",
"\u00d7",
"\u2606",
"\u3010",
"\u3011",
"\u4e00",
"\u4e03",
"\u4e07",
"\u4e09",
"\u4e0a",
"\u4e0b",
"\u4e0d",
"\u4e0e",
"\u4e13",
"\u4e16",
"\u4e1c",
"\u4e1d",
"\u4e24",
"\u4e2a",
"\u4e2b",
"\u4e2d",
"\u4e30",
"\u4e3a",
"\u4e3b",
"\u4e3d",
"\u4e3e",
"\u4e43",
"\u4e48",
"\u4e50",
"\u4e86",
"\u4e8c",
"\u4e91",
"\u4e92",
"\u4e94",
"\u4ea7\u54c1",
"\u4eac",
"\u4eae",
"\u4eba",
"\u4ec0",
"\u4ed8",
"\u4ef6",
"\u4f0d",
"\u4f1f",
"\u4f38",
"\u4f3c",
"\u4f53",
"\u4f5c",
"\u4f69",
"\u4fa8",
"\u4fdd",
"\u4fe1",
"\u4fee",
"\u5012",
"\u502a",
"\u505a",
"\u513f",
"\u5149",
"\u514b",
"\u515c",
"\u516c",
"\u516d",
"\u5170",
"\u5171",
"\u5177",
"\u5185",
"\u5199",
"\u51b0",
"\u51bb",
"\u51c0",
"\u51c9",
"\u51e4",
"\u51fa",
"\u5206",
"\u5207",
"\u5229",
"\u522b",
"\u522e",
"\u522e\u522e\u819c\u5c3a\u5bf8",
"\u5230",
"\u5236",
"\u5237",
"\u523b",
"\u529b",
"\u52a0",
"\u52a0\u6025",
"\u52a8",
"\u5305",
"\u5316",
"\u5317",
"\u533a",
"\u533b",
"\u5341",
"\u5343",
"\u534a",
"\u534e",
"\u5355",
"\u5355\u53f7",
"\u5357",
"\u5361",
"\u5370",
"\u5382",
"\u538b",
"\u5398",
"\u53a8",
"\u53cc",
"\u53d1",
"\u53e4",
"\u53eb",
"\u53f6",
"\u53f7",
"\u53f8",
"\u53fb",
"\u5403",
"\u5404",
"\u5408",
"\u5409",
"\u540c",
"\u540d",
"\u5416",
"\u5446",
"\u548c",
"\u5495",
"\u5496",
"\u54aa",
"\u54c1",
"\u54c6",
"\u54c8",
"\u54d1",
"\u554a",
"\u5561",
"\u5566",
"\u5584",
"\u55b7",
"\u563f",
"\u565c",
"\u56db",
"\u56de",
"\u56e1",
"\u56e2",
"\u56ed",
"\u56fd",
"\u56fe",
"\u5706",
"\u5728",
"\u5730",
"\u573a",
"\u5740",
"\u574a",
"\u574f",
"\u5766",
"\u579a",
"\u57ce",
"\u57fa",
"\u58ee",
"\u58f0",
"\u58f3",
"\u58f9",
"\u5904",
"\u5907",
"\u590d",
"\u590f",
"\u5915",
"\u591a",
"\u5927",
"\u5929",
"\u5934",
"\u5947",
"\u5948",
"\u5956",
"\u5957",
"\u5973",
"\u597d",
"\u5986",
"\u5988",
"\u598d",
"\u5999",
"\u59ae",
"\u59d0",
"\u59d1",
"\u59ec",
"\u59ff",
"\u5a18",
"\u5a1c",
"\u5a1f",
"\u5b50",
"\u5b54",
"\u5b57",
"\u5b5f",
"\u5b64",
"\u5b66",
"\u5b69",
"\u5b81",
"\u5b85",
"\u5b87",
"\u5b89",
"\u5b8b",
"\u5b9a",
"\u5b9d",
"\u5ba2",
"\u5bb0",
"\u5bb6",
"\u5bb9",
"\u5bbe",
"\u5bc4",
"\u5bf8",
"\u5c01",
"\u5c0f",
"\u5c1d",
"\u5c27",
"\u5c3a",
"\u5c3a\u5bf8",
"\u5c3c",
"\u5c71",
"\u5c81",
"\u5c9b",
"\u5ddd",
"\u5dde",
"\u5de5",
"\u5de5\u827a",
"\u5de6",
"\u5deb",
"\u5dee",
"\u5df1",
"\u5e02",
"\u5e03",
"\u5e06",
"\u5e08",
"\u5e0c",
"\u5e1c",
"\u5e45",
"\u5e54",
"\u5e72",
"\u5e73",
"\u5e74",
"\u5e7f",
"\u5e86",
"\u5e8f",
"\u5e95",
"\u5ea6",
"\u5efa",
"\u5f04",
"\u5f0f",
"\u5f20",
"\u5f39",
"\u5f69",
"\u5f71",
"\u5f84",
"\u5fbd",
"\u5fc3",
"\u5fc6",
"\u601d",
"\u6021",
"\u6025",
"\u6027",
"\u602a",
"\u603b",
"\u610f",
"\u61a8",
"\u6210",
"\u6237",
"\u624b",
"\u6253",
"\u6263",
"\u627e",
"\u627f",
"\u62a4",
"\u62c9",
"\u62fe",
"\u6302",
"\u6309",
"\u6392",
"\u6446",
"\u644a",
"\u6495",
"\u6539",
"\u653b",
"\u653e",
"\u653f",
"\u654f",
"\u6551",
"\u6570",
"\u6570\u91cf",
"\u6587",
"\u658c",
"\u6599",
"\u65a4",
"\u65b9",
"\u65cf",
"\u65d7",
"\u65e0",
"\u65e5",
"\u65e9",
"\u65f6",
"\u660e",
"\u6613",
"\u6615",
"\u661f",
"\u662f",
"\u6643",
"\u6653",
"\u665a",
"\u6668",
"\u667a",
"\u66f2",
"\u6700",
"\u6709",
"\u6714",
"\u671f",
"\u6728",
"\u6746",
"\u674e",
"\u6750\u8d28",
"\u6761",
"\u6770",
"\u677f",
"\u6797",
"\u679c",
"\u679d",
"\u67ab",
"\u67c4",
"\u67d1",
"\u67d2",
"\u67d4",
"\u6811",
"\u6816",
"\u6837",
"\u6839",
"\u683c",
"\u6843",
"\u6865",
"\u6881",
"\u6885",
"\u6893",
"\u68a6",
"\u68cd",
"\u6930",
"\u697c",
"\u6a21",
"\u6a58",
"\u6a59",
"\u6a80",
"\u6b21",
"\u6b23",
"\u6b27",
"\u6b3e",
"\u6bcf",
"\u6c34",
"\u6c49",
"\u6c5f",
"\u6c7d",
"\u6c81",
"\u6cb3",
"\u6cbb",
"\u6ce1",
"\u6ce2",
"\u6ce8",
"\u6cea",
"\u6cf0",
"\u6d01",
"\u6d12",
"\u6d25",
"\u6d41",
"\u6d59",
"\u6d77",
"\u6db5",
"\u6dcb",
"\u6df7",
"\u6dfc",
"\u6e14",
"\u6e21",
"\u6e2f",
"\u6e56",
"\u6e58",
"\u6e7e",
"\u6e90",
"\u6f47",
"\u706b",
"\u7070",
"\u7075",
"\u70ad",
"\u70ae",
"\u70b9",
"\u70df",
"\u70eb",
"\u7167",
"\u718a",
"\u7231",
"\u7247",
"\u7248",
"\u7259",
"\u725b",
"\u7269",
"\u7279",
"\u72ec",
"\u732b",
"\u738b",
"\u73ab",
"\u73b0",
"\u73b2",
"\u73e0",
"\u73ed",
"\u7433",
"\u7470",
"\u74dc",
"\u7518",
"\u751c",
"\u751f",
"\u7528",
"\u7528\u6237\u4fe1\u606f",
"\u75d5",
"\u767d",
"\u767e",
"\u7684",
"\u76ae",
"\u76f4",
"\u76f8",
"\u7701",
"\u77e5",
"\u77f3",
"\u7801",
"\u7802",
"\u786c",
"\u7941",
"\u798f",
"\u79be",
"\u79cb",
"\u79cd",
"\u7a7a",
"\u7a7f",
"\u7a9d",
"\u7ae0",
"\u7aef",
"\u7b19",
"\u7b2c",
"\u7b3a",
"\u7b52",
"\u7b7e",
"\u7bb1",
"\u7c73",
"\u7c91",
"\u7c98",
"\u7ca5",
"\u7ccd",
"\u7cd6",
"\u7d2b",
"\u7ea2",
"\u7eb1",
"\u7eb8",
"\u7eb9",
"\u7ebf",
"\u7ec4",
"\u7ecf",
"\u7edf",
"\u7ef3",
"\u7eff",
"\u7f0e",
"\u7f1d",
"\u7f29",
"\u7f2a",
"\u7f51",
"\u7f8e",
"\u7fbd",
"\u7fca",
"\u7fd4",
"\u8001",
"\u8010",
"\u803f",
"\u8083",
"\u80e1",
"\u80f6",
"\u80fd",
"\u8111",
"\u819c",
"\u81ea",
"\u81f4",
"\u820d",
"\u8239",
"\u8272",
"\u827a",
"\u827e",
"\u8299",
"\u82ad",
"\u82b1",
"\u82cf",
"\u82f1",
"\u8303",
"\u8304",
"\u8309",
"\u831c",
"\u8336",
"\u8339",
"\u8349",
"\u8363",
"\u8389",
"\u8393",
"\u83c7",
"\u83dc",
"\u840c",
"\u8425",
"\u8431",
"\u843d",
"\u8499",
"\u84c9",
"\u84dd",
"\u85af",
"\u85cf",
"\u86cb",
"\u86d9",
"\u874e",
"\u884c",
"\u8865",
"\u88c1",
"\u88c5",
"\u897f",
"\u8981",
"\u8986",
"\u89d2",
"\u8ba1",
"\u8bb8",
"\u8bbe",
"\u8bfa",
"\u8d21",
"\u8d22",
"\u8d27",
"\u8d28",
"\u8d34",
"\u8d35",
"\u8d70",
"\u8d77",
"\u8d85",
"\u8f66",
"\u8f6c",
"\u8fb9",
"\u8fbd",
"\u8fce",
"\u8fd9",
"\u8fdc",
"\u8ff7",
"\u9001",
"\u900f",
"\u9053",
"\u90c1",
"\u914d",
"\u9152",
"\u9171",
"\u9192",
"\u91ca",
"\u91cc",
"\u91cd",
"\u91cf",
"\u91d1",
"\u946b",
"\u94b0",
"\u94dc",
"\u94ed",
"\u94f6",
"\u9542",
"\u95f4",
"\u9632",
"\u9633",
"\u963f",
"\u9648",
"\u9655",
"\u9675",
"\u96c5",
"\u96e8",
"\u96ea",
"\u96f6",
"\u9732",
"\u9752",
"\u9762",
"\u9879",
"\u987a",
"\u9891",
"\u9897",
"\u9898",
"\u989c",
"\u98ce",
"\u98de",
"\u98df",
"\u996d",
"\u9970",
"\u9986",
"\u9999",
"\u9a74",
"\u9ad8",
"\u9b3c",
"\u9c7c",
"\u9e1f",
"\u9e25",
"\u9e45",
"\u9e7f",
"\u9ea6",
"\u9ebb",
"\u9ecf",
"\u9ed1",
"\u9f99",
"\u9f9f",
"\uff08",
"\uff09",
"\uff0c",
"\uff1a",
"\uff1b"
]
@@ -0,0 +1,3 @@
{
"mode":"default"
}
@@ -0,0 +1,135 @@
[paths]
train = null
dev = null
vectors = null
init_tok2vec = null
[system]
seed = 0
gpu_allocator = null
[nlp]
lang = "zh"
pipeline = ["ner"]
disabled = []
before_creation = null
after_creation = null
after_pipeline_creation = null
batch_size = 1000
vectors = {"@vectors":"spacy.Vectors.v1"}
[nlp.tokenizer]
@tokenizers = "spacy.zh.ChineseTokenizer"
segmenter = "char"
[components]
[components.ner]
factory = "ner"
incorrect_spans_key = null
moves = null
scorer = {"@scorers":"spacy.ner_scorer.v1"}
update_with_oracle_cut_size = 100
[components.ner.model]
@architectures = "spacy.TransitionBasedParser.v2"
state_type = "ner"
extra_state_tokens = false
hidden_width = 64
maxout_pieces = 2
use_upper = true
nO = null
[components.ner.model.tok2vec]
@architectures = "spacy.HashEmbedCNN.v2"
pretrained_vectors = null
width = 96
depth = 4
embed_size = 2000
window_size = 1
maxout_pieces = 3
subword_features = true
[corpora]
[corpora.dev]
@readers = "spacy.Corpus.v1"
path = ${paths.dev}
gold_preproc = false
max_length = 0
limit = 0
augmenter = null
[corpora.train]
@readers = "spacy.Corpus.v1"
path = ${paths.train}
gold_preproc = false
max_length = 0
limit = 0
augmenter = null
[training]
seed = ${system.seed}
gpu_allocator = ${system.gpu_allocator}
dropout = 0.1
accumulate_gradient = 1
patience = 1600
max_epochs = 0
max_steps = 20000
eval_frequency = 200
frozen_components = []
annotating_components = []
dev_corpus = "corpora.dev"
train_corpus = "corpora.train"
before_to_disk = null
before_update = null
[training.batcher]
@batchers = "spacy.batch_by_words.v1"
discard_oversize = false
tolerance = 0.2
get_length = null
[training.batcher.size]
@schedules = "compounding.v1"
start = 100
stop = 1000
compound = 1.001
t = 0.0
[training.logger]
@loggers = "spacy.ConsoleLogger.v1"
progress_bar = false
[training.optimizer]
@optimizers = "Adam.v1"
beta1 = 0.9
beta2 = 0.999
L2_is_weight_decay = true
L2 = 0.01
grad_clip = 1.0
use_averages = false
eps = 0.00000001
learn_rate = 0.001
[training.score_weights]
ents_f = 1.0
ents_p = 0.0
ents_r = 0.0
ents_per_type = null
[pretraining]
[initialize]
vectors = ${paths.vectors}
init_tok2vec = ${paths.init_tok2vec}
vocab_data = null
lookups = null
before_init = null
after_init = null
[initialize.components]
[initialize.tokenizer]
pkuseg_model = null
pkuseg_user_dict = "default"
@@ -0,0 +1,35 @@
{
"lang":"zh",
"name":"pipeline",
"version":"0.0.0",
"spacy_version":">=3.8.7,<3.9.0",
"description":"",
"author":"",
"email":"",
"url":"",
"license":"",
"spacy_git_version":"4b65aa7",
"vectors":{
"width":0,
"vectors":0,
"keys":0,
"name":null,
"mode":"default"
},
"labels":{
"ner":[
"\u522e\u522e\u819c\u5c3a\u5bf8",
"\u5c3a\u5bf8",
"\u6570\u91cf"
]
},
"pipeline":[
"ner"
],
"components":[
"ner"
],
"disabled":[
]
}
@@ -0,0 +1,13 @@
{
"moves":null,
"update_with_oracle_cut_size":100,
"multitasks":[
],
"min_action_freq":1,
"learn_tokens":false,
"beam_width":1,
"beam_density":0.0,
"beam_update_prob":0.0,
"incorrect_spans_key":null
}

Some files were not shown because too many files have changed in this diff Show More