Build lightweight AI agent admin

This commit is contained in:
Codex
2026-06-08 18:14:59 +08:00
commit e164840f43
2530 changed files with 435693 additions and 0 deletions
View File
+29
View File
@@ -0,0 +1,29 @@
from datetime import datetime
from nanoid import generate
from sqlalchemy import Column, String, DateTime, Boolean, Integer
from sqlalchemy.sql import func
from app.database import Base
def generate_nanoid() -> str:
"""生成21位的NanoId"""
return generate(size=21)
class BaseModel(Base):
"""
公共基础模型
所有业务模型都应继承此类
"""
__abstract__ = True
id = Column(String(21), primary_key=True, default=generate_nanoid, comment="主键ID(NanoId)")
sort = Column(Integer, default=0, comment="排序")
is_deleted = Column(Boolean, default=False, index=True, comment="是否删除")
sys_create_datetime = Column(DateTime, server_default=func.now(), index=True, comment="创建时间")
sys_update_datetime = Column(DateTime, server_default=func.now(), onupdate=func.now(), index=True, comment="更新时间")
sys_creator_id = Column(String(21), nullable=True, index=True, comment="创建人ID(逻辑外键关联core_user")
sys_modifier_id = Column(String(21), nullable=True, comment="修改人ID(逻辑外键关联core_user")
sys_dept_id = Column(String(21), nullable=True, index=True, comment="部门ID(逻辑外键关联core_dept")
+33
View File
@@ -0,0 +1,33 @@
from datetime import datetime
from typing import Annotated, Generic, TypeVar, List, Optional
from pydantic import BaseModel
from pydantic.functional_serializers import PlainSerializer
from app.timezone import APP_TIMEZONE
T = TypeVar("T")
def _format_datetime(v: datetime) -> str | None:
"""将 datetime 转换为配置时区并格式化为字符串"""
if v is None:
return None
if v.tzinfo is not None:
v = v.astimezone(APP_TIMEZONE)
return v.strftime("%Y-%m-%d %H:%M:%S")
CSTDatetime = Annotated[datetime, PlainSerializer(_format_datetime, return_type=str)]
class PaginatedResponse(BaseModel, Generic[T]):
"""通用分页响应模型"""
items: List[T]
total: int
class ResponseModel(BaseModel):
"""通用响应模型"""
message: str = "success"
data: Optional[dict | list] = None
+923
View File
@@ -0,0 +1,923 @@
from io import BytesIO
from typing import TypeVar, Generic, Type, Optional, List, Tuple, Dict, Callable, Any, ClassVar
from sqlalchemy import select, func, desc
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from app.base_model import BaseModel as DBBaseModel
from app.data_scope_utils import get_data_scope_filter, apply_data_scope_to_conditions
from utils.excel import ExcelHandler
from utils.context import get_current_user_id_from_context, get_current_user_info_from_context
T = TypeVar("T", bound=DBBaseModel)
CreateSchema = TypeVar("CreateSchema", bound=BaseModel)
UpdateSchema = TypeVar("UpdateSchema", bound=BaseModel)
class BaseService(Generic[T, CreateSchema, UpdateSchema]):
"""
通用服务基类
提供增删改查和Excel导入导出的通用实现
资源类型自动生成规则:
1. 如果子类定义了 RESOURCE_TYPE,直接使用
2. 如果没有定义,根据 model.__tablename__ 自动生成(去除 core_/sys_ 等前缀)
3. 如果没有表名,根据 model.__name__ 自动生成(驼峰转下划线)
"""
# 子类必须定义
model: ClassVar[Type[DBBaseModel]]
# 资源类型(用于资源类型绑定的数据权限),子类可定义,不定义则自动生成
RESOURCE_TYPE: ClassVar[Optional[str]] = None
# 资源显示名称(可选,用于前端显示)
RESOURCE_DISPLAY_NAME: ClassVar[Optional[str]] = None
# Excel导入导出列映射,子类可覆盖
excel_columns: ClassVar[Dict[str, str]]
excel_sheet_name: ClassVar[str]
# 字段元数据(用于列权限),子类可定义
FIELD_METADATA: ClassVar[Dict[str, Dict[str, Any]]] = {}
def __init_subclass__(cls, **kwargs):
"""
子类初始化时自动注册资源类型和生成字段元数据
"""
super().__init_subclass__(**kwargs)
# 只处理具体的 Service 子类(有 model 属性的)
if hasattr(cls, 'model') and cls.model is not None:
# 如果没有定义 RESOURCE_TYPE,自动生成
if cls.RESOURCE_TYPE is None:
from app.resource_registry import auto_generate_resource_type
cls.RESOURCE_TYPE = auto_generate_resource_type(cls.model)
# 如果没有定义 FIELD_METADATA 或为空,自动生成
if not cls.FIELD_METADATA or len(cls.FIELD_METADATA) == 0:
from app.field_metadata_generator import auto_generate_field_metadata
cls.FIELD_METADATA = auto_generate_field_metadata(cls.model)
# 注册到资源注册表
if cls.RESOURCE_TYPE:
from app.resource_registry import ResourceRegistry
ResourceRegistry.register(
resource_type=cls.RESOURCE_TYPE,
service_class=cls,
display_name=cls.RESOURCE_DISPLAY_NAME
)
@classmethod
async def create(
cls,
db: AsyncSession,
data: CreateSchema,
auto_commit: bool = True,
current_user_id: Optional[str] = None
) -> Any:
"""
创建记录
:param db: 数据库会话
:param data: 创建数据Schema
:param auto_commit: 是否自动提交,默认True。在事务中使用时设为False
:param current_user_id: 当前用户ID(可选),如果不传则自动从上下文获取
:return: 创建的记录
"""
db_obj = cls.model(**data.model_dump())
# 从上下文获取用户信息
user_info = get_current_user_info_from_context()
# 自动设置创建人ID
# 优先使用传入的 current_user_id,如果没有则从上下文获取
user_id = current_user_id or (user_info.get('user_id') if user_info else None)
if user_id and hasattr(db_obj, 'sys_creator_id'):
db_obj.sys_creator_id = user_id
# 自动设置部门ID
if user_info and hasattr(db_obj, 'sys_dept_id'):
dept_id = user_info.get('dept_id')
if dept_id:
db_obj.sys_dept_id = dept_id
db.add(db_obj)
if auto_commit:
await db.commit()
await db.refresh(db_obj)
else:
await db.flush()
await db.refresh(db_obj)
return db_obj
@classmethod
async def get_by_id(cls, db: AsyncSession, record_id: str) -> Optional[Any]:
"""
根据ID获取单条记录(排除已删除)
:param db: 数据库会话
:param record_id: 记录ID
:return: 记录或None
"""
result = await db.execute(
select(cls.model).where(
cls.model.id == record_id,
cls.model.is_deleted == False # noqa: E712
)
)
return result.scalar_one_or_none()
@classmethod
async def get_list(
cls,
db: AsyncSession,
page: int = 1,
page_size: int = 20,
filters: Optional[List[Any]] = None
) -> Tuple[List[Any], int]:
"""
获取列表(分页,排除已删除)
:param db: 数据库会话
:param page: 页码
:param page_size: 每页数量
:param filters: 额外的过滤条件列表
:return: (数据列表, 总数)
"""
base_query = select(cls.model).where(cls.model.is_deleted == False) # noqa: E712
# 添加额外过滤条件
if filters:
for f in filters:
base_query = base_query.where(f)
# 获取总数
count_result = await db.execute(
select(func.count()).select_from(base_query.subquery())
)
total = count_result.scalar() or 0
# 计算offset
offset = (page - 1) * page_size
# 获取分页数据
result = await db.execute(
base_query.order_by(
desc(cls.model.sort),
desc(cls.model.sys_create_datetime)
)
.offset(offset)
.limit(page_size)
)
items = list(result.scalars().all())
return items, total
@classmethod
async def update(
cls,
db: AsyncSession,
record_id: str,
data: UpdateSchema,
auto_commit: bool = True,
current_user_id: Optional[str] = None
) -> Optional[Any]:
"""
更新记录
:param db: 数据库会话
:param record_id: 记录ID
:param data: 更新数据Schema
:param auto_commit: 是否自动提交,默认True。在事务中使用时设为False
:param current_user_id: 当前用户ID(可选),如果不传则自动从上下文获取
:return: 更新后的记录或None
"""
db_obj = await cls.get_by_id(db, record_id)
if not db_obj:
return None
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_obj, field, value)
# 自动设置修改人ID
# 优先使用传入的 current_user_id,如果没有则从上下文获取
user_id = current_user_id or get_current_user_id_from_context()
if user_id and hasattr(db_obj, 'sys_modifier_id'):
db_obj.sys_modifier_id = user_id
if auto_commit:
await db.commit()
await db.refresh(db_obj)
else:
await db.flush()
await db.refresh(db_obj)
return db_obj
@classmethod
async def delete(
cls,
db: AsyncSession,
record_id: str,
hard: bool = True,
auto_commit: bool = True
) -> bool:
"""
删除记录
:param db: 数据库会话
:param record_id: 记录ID
:param hard: True为物理删除,False为逻辑删除
:param auto_commit: 是否自动提交,默认True。在事务中使用时设为False
:return: 是否删除成功
"""
db_obj = await cls.get_by_id(db, record_id)
if not db_obj:
return False
if hard:
await db.delete(db_obj)
else:
db_obj.is_deleted = True
if auto_commit:
await db.commit()
else:
await db.flush()
return True
@classmethod
async def batch_delete(
cls,
db: AsyncSession,
ids: List[str],
hard: bool = False,
auto_commit: bool = True
) -> Tuple[int, int]:
"""
批量删除记录
:param db: 数据库会话
:param ids: 记录ID列表
:param hard: True为物理删除,False为逻辑删除
:param auto_commit: 是否自动提交,默认True。在事务中使用时设为False
:return: (成功数, 失败数)
"""
success_count = 0
fail_count = 0
for record_id in ids:
db_obj = await cls.get_by_id(db, record_id)
if db_obj:
if hard:
await db.delete(db_obj)
else:
db_obj.is_deleted = True
success_count += 1
else:
fail_count += 1
if success_count > 0:
if auto_commit:
await db.commit()
else:
await db.flush()
return success_count, fail_count
@classmethod
async def update_with_data_scope(
cls,
db: AsyncSession,
record_id: str,
data: UpdateSchema,
auto_commit: bool = True,
current_user_id: Optional[str] = None,
dept_field: str = "sys_dept_id",
user_field: str = "sys_creator_id"
) -> Optional[Any]:
"""
更新记录(带数据权限检查)
自动从上下文获取当前用户信息和请求信息,检查是否有权限修改此记录
:param db: 数据库会话
:param record_id: 记录ID
:param data: 更新数据Schema
:param auto_commit: 是否自动提交
:param current_user_id: 当前用户ID(可选)
:param dept_field: 部门字段名
:param user_field: 用户字段名
:return: 更新后的记录或None(记录不存在或无权限)
"""
# 从上下文获取用户信息和请求信息
user_info = get_current_user_info_from_context()
if not user_info:
return None
# 获取数据权限过滤条件(优先使用资源类型绑定)
data_scope_filter = await cls._get_data_scope_filter(db, user_info)
# 构建查询,应用数据权限过滤
query = select(cls.model).where(
cls.model.id == record_id,
cls.model.is_deleted == False # noqa: E712
)
# 应用数据权限过滤
query = cls._apply_data_scope_to_query(
query=query,
data_scope_filter=data_scope_filter,
dept_field=dept_field,
user_field=user_field
)
# 查询记录
result = await db.execute(query)
db_obj = result.scalar_one_or_none()
# 如果没有找到记录,说明记录不存在或无权限
if not db_obj:
return None
# 更新字段
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_obj, field, value)
# 自动设置修改人ID
user_id = current_user_id or user_info.get("user_id")
if user_id and hasattr(db_obj, 'sys_modifier_id'):
db_obj.sys_modifier_id = user_id
if auto_commit:
await db.commit()
await db.refresh(db_obj)
else:
await db.flush()
await db.refresh(db_obj)
return db_obj
@classmethod
async def delete_with_data_scope(
cls,
db: AsyncSession,
record_id: str,
hard: bool = False,
auto_commit: bool = True,
dept_field: str = "sys_dept_id",
user_field: str = "sys_creator_id"
) -> bool:
"""
删除记录(带数据权限检查)
自动从上下文获取当前用户信息和请求信息,检查是否有权限删除此记录
:param db: 数据库会话
:param record_id: 记录ID
:param hard: True为物理删除,False为逻辑删除
:param auto_commit: 是否自动提交
:param dept_field: 部门字段名
:param user_field: 用户字段名
:return: 是否删除成功(False表示记录不存在或无权限)
"""
# 从上下文获取用户信息和请求信息
user_info = get_current_user_info_from_context()
if not user_info:
return False
# 获取数据权限过滤条件(优先使用资源类型绑定)
data_scope_filter = await cls._get_data_scope_filter(db, user_info)
# 构建查询,应用数据权限过滤
query = select(cls.model).where(
cls.model.id == record_id,
cls.model.is_deleted == False # noqa: E712
)
# 应用数据权限过滤
query = cls._apply_data_scope_to_query(
query=query,
data_scope_filter=data_scope_filter,
dept_field=dept_field,
user_field=user_field
)
# 查询记录
result = await db.execute(query)
db_obj = result.scalar_one_or_none()
# 如果没有找到记录,说明记录不存在或无权限
if not db_obj:
return False
# 执行删除
if hard:
await db.delete(db_obj)
else:
db_obj.is_deleted = True
if auto_commit:
await db.commit()
else:
await db.flush()
return True
@classmethod
async def export_to_excel(
cls,
db: AsyncSession,
data_converter: Optional[Callable[[Any], Dict[str, Any]]] = None
) -> BytesIO:
"""
导出数据到Excel
:param db: 数据库会话
:param data_converter: 数据转换函数,将model转为dict,子类可自定义
:return: Excel文件的BytesIO对象
"""
result = await db.execute(
select(cls.model).where(cls.model.is_deleted == False) # noqa: E712
.order_by(desc(cls.model.sort), desc(cls.model.sys_create_datetime))
)
items = result.scalars().all()
# 转换数据
if data_converter:
data = [data_converter(item) for item in items]
else:
# 默认转换:使用excel_columns中的字段
data = [
{field: getattr(item, field, "") for field in cls.excel_columns.keys()}
for item in items
]
return ExcelHandler.export_to_excel(data, cls.excel_columns, cls.excel_sheet_name)
@classmethod
async def import_from_excel(
cls,
db: AsyncSession,
file_content: bytes,
row_processor: Optional[Callable[[Dict[str, Any]], Optional[Any]]] = None
) -> Tuple[int, int]:
"""
从Excel导入数据
:param db: 数据库会话
:param file_content: Excel文件内容
:param row_processor: 行数据处理函数,将dict转为model实例,子类可自定义
:return: (成功数, 失败数)
"""
rows = ExcelHandler.import_from_excel(file_content, cls.excel_columns)
success_count = 0
fail_count = 0
for row in rows:
try:
if row_processor:
db_obj = row_processor(row)
else:
# 默认处理:直接创建model实例
db_obj = cls.model(**row)
if db_obj:
db.add(db_obj)
success_count += 1
except Exception:
fail_count += 1
if success_count > 0:
await db.commit()
return success_count, fail_count
@classmethod
def get_import_template(cls) -> BytesIO:
"""获取导入模板"""
return ExcelHandler.generate_template(cls.excel_columns, cls.excel_sheet_name)
@classmethod
async def check_unique(
cls,
db: AsyncSession,
field: str,
value: Any,
exclude_id: Optional[str] = None
) -> bool:
"""
检查字段值是否唯一
:param db: 数据库会话
:param field: 字段名
:param value: 字段值
:param exclude_id: 排除的记录ID(用于更新时排除自身)
:return: True表示唯一,False表示已存在
"""
query = select(cls.model).where(
getattr(cls.model, field) == value,
cls.model.is_deleted == False # noqa: E712
)
# 更新时排除自身
if exclude_id:
query = query.where(cls.model.id != exclude_id)
result = await db.execute(query)
return result.scalar_one_or_none() is None
@classmethod
async def get_by_field(
cls,
db: AsyncSession,
field: str,
value: Any
) -> Optional[Any]:
"""
根据字段获取单条记录
:param db: 数据库会话
:param field: 字段名
:param value: 字段值
:return: 记录或None
"""
result = await db.execute(
select(cls.model).where(
getattr(cls.model, field) == value,
cls.model.is_deleted == False # noqa: E712
)
)
return result.scalar_one_or_none()
@classmethod
async def exists(
cls,
db: AsyncSession,
filters: List[Any]
) -> bool:
"""
检查是否存在符合条件的记录
:param db: 数据库会话
:param filters: 过滤条件列表
:return: True表示存在,False表示不存在
"""
query = select(cls.model).where(cls.model.is_deleted == False) # noqa: E712
for f in filters:
query = query.where(f)
result = await db.execute(query)
return result.scalar_one_or_none() is not None
@classmethod
async def get_list_with_data_scope(
cls,
db: AsyncSession,
page: int = 1,
page_size: int = 20,
filters: Optional[List[Any]] = None,
dept_field: str = "sys_dept_id",
user_field: str = "sys_creator_id"
) -> Tuple[List[Any], int]:
"""
获取列表(分页,带数据权限过滤)
自动从上下文获取当前用户信息和请求信息,无需手动传递任何参数
数据权限优先级:
1. 优先使用资源类型绑定的数据权限(ResourceDataScopeConfig
2. 如果没有配置,回退到 API 绑定的数据权限(Permission.data_scope
3. 如果都没有,默认全部数据
:param db: 数据库会话
:param page: 页码
:param page_size: 每页数量
:param filters: 额外的过滤条件列表
:param dept_field: 部门字段名(默认sys_dept_id
:param user_field: 用户字段名(默认sys_creator_id
:return: (数据列表, 总数)
"""
# 从上下文获取用户信息和请求信息
user_info = get_current_user_info_from_context()
if not user_info:
# 如果没有用户信息,返回空列表(不应该发生)
return [], 0
# 获取数据权限过滤条件
data_scope_filter = await cls._get_data_scope_filter(db, user_info)
# 构建基础查询
base_query = select(cls.model).where(cls.model.is_deleted == False) # noqa: E712
# 添加业务过滤条件
if filters:
for f in filters:
base_query = base_query.where(f)
# 应用数据权限过滤
base_query = cls._apply_data_scope_to_query(
query=base_query,
data_scope_filter=data_scope_filter,
dept_field=dept_field,
user_field=user_field
)
# 获取总数
count_result = await db.execute(
select(func.count()).select_from(base_query.subquery())
)
total = count_result.scalar() or 0
# 计算offset
offset = (page - 1) * page_size
# 获取分页数据
result = await db.execute(
base_query.order_by(
desc(cls.model.sort),
desc(cls.model.sys_create_datetime)
)
.offset(offset)
.limit(page_size)
)
items = list(result.scalars().all())
return items, total
@classmethod
async def _get_data_scope_filter(
cls,
db: AsyncSession,
user_info: Dict[str, Any]
) -> Dict[str, Any]:
"""
获取数据权限过滤条件(基于资源类型绑定)
使用 data_scope_utils 中的统一实现
:param db: 数据库会话
:param user_info: 用户信息字典
:return: 数据权限过滤条件字典
"""
return await get_data_scope_filter(db, cls.RESOURCE_TYPE, user_info)
@classmethod
def _apply_data_scope_to_query(
cls,
query,
data_scope_filter: Dict[str, Any],
dept_field: str = "sys_dept_id",
user_field: str = "sys_creator_id"
):
"""
将数据权限过滤条件应用到查询
使用 data_scope_utils 中的统一实现
:param query: SQLAlchemy查询对象
:param data_scope_filter: 数据权限过滤条件
:param dept_field: 部门字段名
:param user_field: 用户字段名
:return: 应用过滤后的查询对象
"""
conditions = apply_data_scope_to_conditions(
cls.model, data_scope_filter, dept_field, user_field
)
for condition in conditions:
query = query.where(condition)
return query
@classmethod
async def export_to_excel_with_data_scope(
cls,
db: AsyncSession,
data_converter: Optional[Callable[[Any], Dict[str, Any]]] = None,
dept_field: str = "dept_id",
user_field: str = "user_id"
) -> BytesIO:
"""
导出数据到Excel(带数据权限过滤)
自动从上下文获取当前用户信息和请求信息,无需手动传递任何参数
:param db: 数据库会话
:param data_converter: 数据转换函数
:param dept_field: 部门字段名
:param user_field: 用户字段名
:return: Excel文件的BytesIO对象
"""
# 从上下文获取用户信息和请求信息
user_info = get_current_user_info_from_context()
if not user_info:
# 如果没有用户信息,返回空Excel
return ExcelHandler.export_to_excel([], cls.excel_columns, cls.excel_sheet_name)
# 获取数据权限过滤条件(优先使用资源类型绑定)
data_scope_filter = await cls._get_data_scope_filter(db, user_info)
# 构建查询
query = select(cls.model).where(cls.model.is_deleted == False) # noqa: E712
# 应用数据权限过滤
query = cls._apply_data_scope_to_query(
query=query,
data_scope_filter=data_scope_filter,
dept_field=dept_field,
user_field=user_field
)
# 执行查询
result = await db.execute(
query.order_by(desc(cls.model.sort), desc(cls.model.sys_create_datetime))
)
items = result.scalars().all()
# 转换数据
if data_converter:
data = [data_converter(item) for item in items]
else:
data = [
{field: getattr(item, field, "") for field in cls.excel_columns.keys()}
for item in items
]
return ExcelHandler.export_to_excel(data, cls.excel_columns, cls.excel_sheet_name)
# ==================== 字段权限(列权限)相关方法 ====================
@classmethod
async def apply_field_permissions_auto(
cls,
data: Any,
db: AsyncSession,
merge_strategy: str = "most_permissive"
) -> Any:
"""
自动应用字段权限过滤(从上下文获取角色)
:param data: 数据(单个对象或列表)
:param db: 数据库会话
:param merge_strategy: 权限合并策略(most_permissive/most_restrictive
:return: 过滤后的数据
"""
from utils.context import get_current_user_info_from_context
# 从上下文获取用户信息
user_info = get_current_user_info_from_context()
if not user_info or not user_info.get('role_ids'):
return data
# 应用字段权限
return await cls.apply_field_permissions(
data=data,
role_ids=user_info['role_ids'],
db=db,
merge_strategy=merge_strategy
)
@classmethod
async def apply_field_permissions(
cls,
data: Any,
role_ids: List[str],
db: AsyncSession,
merge_strategy: str = "most_permissive"
) -> Any:
"""
应用字段权限过滤
:param data: 数据(单个对象或列表)
:param role_ids: 角色ID列表
:param db: 数据库会话
:param merge_strategy: 权限合并策略(most_permissive/most_restrictive
:return: 过滤后的数据
"""
if not cls.RESOURCE_TYPE or not role_ids:
return data
# 获取字段权限配置
field_perms = await cls._get_field_permissions(role_ids, db, merge_strategy)
# 如果没有配置字段权限,直接返回原数据
if not field_perms:
return data
# 处理单个对象或列表
if isinstance(data, list):
return [cls._filter_fields(item, field_perms) for item in data]
else:
return cls._filter_fields(data, field_perms)
@classmethod
async def _get_field_permissions(
cls,
role_ids: List[str],
db: AsyncSession,
merge_strategy: str = "most_permissive"
) -> Dict[str, Dict]:
"""获取并合并字段权限配置"""
from core.resource_scope.field_permission.service import ResourceFieldPermissionService
from app.field_permission_cache import FieldPermissionCache
# 尝试从缓存获取
cached_perms = await FieldPermissionCache.get_merged(role_ids, cls.RESOURCE_TYPE)
if cached_perms is not None:
return cached_perms
# 获取所有角色的字段权限配置
configs = await ResourceFieldPermissionService.get_by_roles_and_resource(
db, role_ids, cls.RESOURCE_TYPE
)
if not configs:
return {}
# 合并权限
merged_perms = await ResourceFieldPermissionService.merge_field_permissions(
configs, merge_strategy
)
# 缓存结果
await FieldPermissionCache.set_merged(role_ids, cls.RESOURCE_TYPE, merged_perms)
return merged_perms
@classmethod
def _filter_fields(cls, item: Any, field_perms: Dict[str, Dict]) -> Dict:
"""
过滤字段
:param item: 数据项(可以是字典或 ORM 对象)
:param field_perms: 字段权限配置
:return: 过滤后的字典
"""
# 转换为字典
if hasattr(item, '__dict__'):
item_dict = {k: v for k, v in item.__dict__.items() if not k.startswith('_')}
elif hasattr(item, 'dict'):
item_dict = item.dict()
elif isinstance(item, dict):
item_dict = item
else:
return item
filtered = {}
for field_name, value in item_dict.items():
perm = field_perms.get(field_name, {})
permission_type = perm.get('permission', 'read')
if permission_type == 'hidden':
# 隐藏字段,不返回
continue
elif permission_type == 'masked':
# 脱敏处理
filtered[field_name] = cls._mask_value(value, perm.get('mask_rule'))
else:
# read 或 write 权限,正常返回
filtered[field_name] = value
return filtered
@classmethod
def _mask_value(cls, value: Any, mask_rule: Optional[str]) -> str:
"""
脱敏处理
:param value: 原始值
:param mask_rule: 脱敏规则
:return: 脱敏后的值
"""
if not value:
return value
value_str = str(value)
if mask_rule == "phone":
# 手机号脱敏:138****5678
if len(value_str) == 11:
return f"{value_str[:3]}****{value_str[-4:]}"
elif mask_rule == "email":
# 邮箱脱敏:abc***@example.com
if "@" in value_str:
local, domain = value_str.split("@", 1)
if len(local) > 3:
return f"{local[:3]}***@{domain}"
return f"{local[0]}***@{domain}"
elif mask_rule == "id_card":
# 身份证脱敏:110***********1234
if len(value_str) >= 8:
return f"{value_str[:3]}***********{value_str[-4:]}"
elif mask_rule == "name":
# 姓名脱敏:张*
if len(value_str) > 1:
return f"{value_str[0]}*"
return "*"
# 默认脱敏:显示前后各2个字符
if len(value_str) > 4:
return f"{value_str[:2]}***{value_str[-2:]}"
return "***"
+241
View File
@@ -0,0 +1,241 @@
"""
带缓存的通用服务基类
继承BaseService,添加Redis缓存支持
"""
from typing import TypeVar, Type, Optional, List, Tuple, Dict, Callable, Any, ClassVar
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from app.base_model import BaseModel as DBBaseModel
from app.base_service import BaseService
from utils.redis import CacheManager
T = TypeVar("T", bound=DBBaseModel)
CreateSchema = TypeVar("CreateSchema", bound=BaseModel)
UpdateSchema = TypeVar("UpdateSchema", bound=BaseModel)
class CacheService(BaseService[T, CreateSchema, UpdateSchema]):
"""
带缓存的通用服务基类
继承BaseService,添加Redis缓存支持
子类需要定义:
- model: 数据模型类
- cache_prefix: 缓存key前缀
- cache_expire: 缓存过期时间(秒)
可选覆盖:
- _serialize_for_cache: 自定义序列化方法
"""
# 子类必须定义
model: ClassVar[Type[DBBaseModel]]
# 缓存配置,子类可覆盖
cache_prefix: ClassVar[str] = ""
cache_expire: ClassVar[int] = 300
# 缓存key模板
CACHE_KEY_DETAIL: ClassVar[str] = "detail:{id}"
CACHE_KEY_LIST: ClassVar[str] = "list:page:{page}:size:{size}"
# 缓存管理器(延迟初始化)
_cache_manager: ClassVar[Optional[CacheManager]] = None
@classmethod
def _get_cache(cls) -> CacheManager:
"""获取缓存管理器(延迟初始化)"""
if cls._cache_manager is None or cls._cache_manager.prefix != f"{cls.cache_prefix}":
cls._cache_manager = CacheManager(prefix=cls.cache_prefix)
return cls._cache_manager
@classmethod
def _serialize_for_cache(cls, item: Any) -> Dict[str, Any]:
"""
将model对象序列化为可缓存的字典
子类可覆盖此方法自定义序列化逻辑
"""
return {
"id": item.id,
"sort": item.sort,
"is_deleted": item.is_deleted,
"sys_create_datetime": str(item.sys_create_datetime),
"sys_update_datetime": str(item.sys_update_datetime),
}
@classmethod
async def create(cls, db: AsyncSession, data: CreateSchema) -> Any:
"""创建记录并清除列表缓存"""
result = await super().create(db, data)
# 清除列表缓存
await cls._get_cache().delete_pattern("list:*")
return result
@classmethod
async def get_by_id(cls, db: AsyncSession, record_id: str) -> Optional[Any]:
"""
根据ID获取记录(优先从缓存获取)
"""
cache = cls._get_cache()
cache_key = cls.CACHE_KEY_DETAIL.format(id=record_id)
# 尝试从缓存获取
cached = await cache.get(cache_key)
if cached:
return cached
# 缓存未命中,从数据库获取
result = await super().get_by_id(db, record_id)
if result:
# 序列化并写入缓存
cache_data = cls._serialize_for_cache(result)
await cache.set(cache_key, cache_data, cls.cache_expire)
return result
@classmethod
async def get_by_id_no_cache(cls, db: AsyncSession, record_id: str) -> Optional[Any]:
"""根据ID获取记录(不使用缓存)"""
return await super().get_by_id(db, record_id)
@classmethod
async def get_list(
cls,
db: AsyncSession,
page: int = 1,
page_size: int = 20,
filters: Optional[List[Any]] = None
) -> Tuple[List[Any], int]:
"""
获取列表(优先从缓存获取,仅缓存无过滤条件的查询)
"""
# 有过滤条件时不使用缓存
if filters:
return await super().get_list(db, page, page_size, filters)
cache = cls._get_cache()
cache_key = cls.CACHE_KEY_LIST.format(page=page, size=page_size)
# 尝试从缓存获取
cached = await cache.get(cache_key)
if cached:
return cached.get("items", []), cached.get("total", 0)
# 缓存未命中,从数据库获取
items, total = await super().get_list(db, page, page_size, filters)
# 序列化并写入缓存
cache_data = {
"items": [cls._serialize_for_cache(item) for item in items],
"total": total
}
await cache.set(cache_key, cache_data, cls.cache_expire)
return items, total
@classmethod
async def get_list_no_cache(
cls,
db: AsyncSession,
page: int = 1,
page_size: int = 20,
filters: Optional[List[Any]] = None
) -> Tuple[List[Any], int]:
"""获取列表(不使用缓存)"""
return await super().get_list(db, page, page_size, filters)
@classmethod
async def update(
cls,
db: AsyncSession,
record_id: str,
data: UpdateSchema
) -> Optional[Any]:
"""更新记录并清除相关缓存"""
result = await super().update(db, record_id, data)
if result:
cache = cls._get_cache()
# 清除单条记录缓存
await cache.delete(cls.CACHE_KEY_DETAIL.format(id=record_id))
# 清除列表缓存
await cache.delete_pattern("list:*")
return result
@classmethod
async def delete(
cls,
db: AsyncSession,
record_id: str,
hard: bool = False
) -> bool:
"""删除记录并清除相关缓存"""
result = await super().delete(db, record_id, hard)
if result:
cache = cls._get_cache()
# 清除单条记录缓存
await cache.delete(cls.CACHE_KEY_DETAIL.format(id=record_id))
# 清除列表缓存
await cache.delete_pattern("list:*")
return result
@classmethod
async def clear_cache(cls, record_id: Optional[str] = None) -> int:
"""
手动清除缓存
:param record_id: 指定ID则只清除该记录缓存,否则清除所有缓存
:return: 清除的key数量
"""
cache = cls._get_cache()
if record_id:
return await cache.delete(cls.CACHE_KEY_DETAIL.format(id=record_id))
else:
return await cache.delete_pattern("*")
@classmethod
async def refresh_cache(cls, db: AsyncSession, record_id: str) -> bool:
"""
刷新指定记录的缓存
:param record_id: 记录ID
:return: 是否成功
"""
# 先删除缓存
await cls._get_cache().delete(cls.CACHE_KEY_DETAIL.format(id=record_id))
# 重新获取(会自动写入缓存)
result = await cls.get_by_id(db, record_id)
return result is not None
@classmethod
async def get_cache_stats(cls, record_id: str) -> Dict[str, Any]:
"""
获取缓存状态信息
:param record_id: 记录ID
:return: 缓存状态信息
"""
cache = cls._get_cache()
cache_key = cls.CACHE_KEY_DETAIL.format(id=record_id)
exists = await cache.exists(cache_key)
ttl = await cache.ttl(cache_key) if exists else -2
return {
"key": f"{cache.prefix}{cache_key}",
"exists": exists,
"ttl": ttl
}
@classmethod
async def import_from_excel(
cls,
db: AsyncSession,
file_content: bytes,
row_processor: Optional[Callable[[Dict[str, Any]], Optional[Any]]] = None
) -> Tuple[int, int]:
"""从Excel导入数据并清除列表缓存"""
result = await super().import_from_excel(db, file_content, row_processor)
# 清除列表缓存
await cls._get_cache().delete_pattern("list:*")
return result
+219
View File
@@ -0,0 +1,219 @@
import os
from typing import Literal, Optional
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""应用配置"""
# 环境标识
ENV: Literal["dev", "uat", "prod"] = "dev"
DEBUG: bool = False
# 应用配置
APP_NAME: str = "FastAPI Demo"
APP_HOST: str = "0.0.0.0"
APP_PORT: int = 8000
# 数据库配置
DB_TYPE: Literal["postgresql", "mysql", "sqlserver"] = "postgresql" # 数据库类型
DB_HOST: str = "localhost"
DB_PORT: int = 5432
DB_USER: str = "postgres"
DB_PASSWORD: str = ""
DB_NAME: str = "fastapi_db"
# 数据库连接URL(可手动配置,否则根据 DB_TYPE 自动拼接)
DATABASE_URL: Optional[str] = None
# 分页配置
PAGE_SIZE: int = 20
PAGE_MAX_SIZE: int = 500
# 时区配置(IANA时区名称,如 Asia/Shanghai, America/New_York, UTC 等)
TIMEZONE: str = "Asia/Shanghai"
# Redis配置
REDIS_HOST: str = "localhost"
REDIS_PORT: int = 6379
REDIS_PASSWORD: str = ""
REDIS_DB: int = 0
REDIS_URL: Optional[str] = None
# 缓存配置
CACHE_DEFAULT_EXPIRE: int = 300 # 默认缓存过期时间(秒)
CACHE_PREFIX: str = "fastapi:" # 缓存key前缀
# JWT配置
JWT_SECRET_KEY: str = "your-strong-secret-key-change-in-production" # JWT密钥,生产环境必须修改
JWT_ALGORITHM: str = "HS256" # JWT算法
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # Access Token过期时间(分钟)
REFRESH_TOKEN_EXPIRE_DAYS: int = 30 # Refresh Token过期时间(天)
ALLOW_MULTI_DEVICE_LOGIN: bool = True # 是否允许多设备同时登录(False=单设备登录,新登录会踢掉旧设备)
# 文件存储配置
FILE_STORAGE_TYPE: str = "minio" # local/oss/minio/azure
FILE_STORAGE_LOCAL_PATH: Optional[str] = None # 本地存储路径
# OSS配置
OSS_ENDPOINT: Optional[str] = None
OSS_ACCESS_KEY_ID: Optional[str] = None
OSS_ACCESS_KEY_SECRET: Optional[str] = None
OSS_BUCKET_NAME: Optional[str] = None
# Minio配置
MINIO_ENDPOINT: Optional[str] = None
MINIO_ACCESS_KEY: Optional[str] = None
MINIO_SECRET_KEY: Optional[str] = None
MINIO_BUCKET_NAME: Optional[str] = None
MINIO_SECURE: bool = False
# Azure配置
AZURE_ACCOUNT_NAME: Optional[str] = None
AZURE_ACCOUNT_KEY: Optional[str] = None
AZURE_CONTAINER_NAME: Optional[str] = None
# OAuth配置
GRANT_ADMIN_TO_OAUTH_USER: bool = True # 是否给OAuth用户授予管理员权限
OAUTH_DEFAULT_DEPT_ID: Optional[str] = 'fbsamU5f2VNtjAJhpGJdy' # OAuth用户默认部门ID
# Gitee OAuth
GITEE_CLIENT_ID: Optional[str] = None
GITEE_CLIENT_SECRET: Optional[str] = None
GITEE_REDIRECT_URI: Optional[str] = 'https://explorer.zq-platform.cn/oauth/gitee/callback'
# GitHub OAuth
GITHUB_CLIENT_ID: Optional[str] = None
GITHUB_CLIENT_SECRET: Optional[str] = None
GITHUB_REDIRECT_URI: Optional[str] = 'https://explorer.zq-platform.cn/oauth/github/callback'
# QQ OAuth
QQ_APP_ID: Optional[str] = None
QQ_APP_KEY: Optional[str] = None
QQ_REDIRECT_URI: Optional[str] = 'https://explorer.zq-platform.cn/oauth/qq/callback'
# Google OAuth
GOOGLE_CLIENT_ID: Optional[str] = None
GOOGLE_CLIENT_SECRET: Optional[str] = None
GOOGLE_REDIRECT_URI: Optional[str] = 'https://explorer.zq-platform.cn/oauth/google/callback'
# 微信 OAuth
WECHAT_APP_ID: Optional[str] = None
WECHAT_APP_SECRET: Optional[str] = None
WECHAT_REDIRECT_URI: Optional[str] = 'https://explorer.zq-platform.cn/oauth/wechat/callback'
# Microsoft OAuth
MICROSOFT_CLIENT_ID: Optional[str] = None
MICROSOFT_CLIENT_SECRET: Optional[str] = None
MICROSOFT_REDIRECT_URI: Optional[str] = 'https://explorer.zq-platform.cn/oauth/microsoft/callback'
# 钉钉 OAuth
DINGTALK_APP_ID: Optional[str] = None
DINGTALK_APP_SECRET: Optional[str] = None
DINGTALK_REDIRECT_URI: Optional[str] = 'https://localhost:5777/oauth/dingtalk/callback'
DINGTALK_H5_REDIRECT_URI: Optional[str] = 'http://localhost:5174/pages/oauth/dingtalk/callback' # uniapp H5 端回调地址
# 飞书 OAuth
FEISHU_APP_ID: Optional[str] = None
FEISHU_APP_SECRET: Optional[str] = None
FEISHU_REDIRECT_URI: Optional[str] = 'https://localhost:5777/oauth/feishu/callback'
FEISHU_H5_REDIRECT_URI: Optional[str] = 'http://localhost:5174/pages/oauth/feishu/callback' # uniapp H5 端回调地址
# 企业微信 OAuth
WECOM_CORP_ID: Optional[str] = None
WECOM_AGENT_ID: Optional[str] = None
WECOM_APP_SECRET: Optional[str] = None
WECOM_REDIRECT_URI: Optional[str] = 'https://localhost:5777/oauth/wecom/callback'
WECOM_H5_REDIRECT_URI: Optional[str] = 'http://localhost:5174/pages/oauth/wecom/callback' # uniapp H5 端回调地址
# SMTP 邮件配置
SMTP_HOST: Optional[str] = None
SMTP_PORT: int = 465
SMTP_USER: Optional[str] = None
SMTP_PASSWORD: Optional[str] = None
SMTP_USE_TLS: bool = True
SMTP_FROM_NAME: Optional[str] = None # 发件人显示名称,默认使用 APP_NAME
SMTP_FROM_EMAIL: Optional[str] = None # 发件人邮箱,默认使用 SMTP_USER
# 钉钉通知配置
DINGTALK_WEBHOOK_URL: Optional[str] = None # 群机器人 Webhook 地址
DINGTALK_WEBHOOK_SECRET: Optional[str] = None # 群机器人签名密钥
DINGTALK_AGENT_ID: Optional[str] = None # 企业内部应用 AgentId(工作通知需要)
DINGTALK_CORP_ID: Optional[str] = None # 企业 CorpId(工作通知需要)
DINGTALK_TODO_PC_URL: Optional[str] = None # 钉钉待办 PC 端跳转基础 URL
DINGTALK_TODO_APP_URL: Optional[str] = None # 钉钉待办移动端跳转基础 URL
# 飞书通知配置
FEISHU_WEBHOOK_URL: Optional[str] = None # 群机器人 Webhook 地址
FEISHU_WEBHOOK_SECRET: Optional[str] = None # 群机器人签名密钥
# 企业微信通知配置
WECOM_WEBHOOK_URL: Optional[str] = None # 群机器人 Webhook 地址
# 微信公众号模板消息配置(复用 WECHAT_APP_ID / WECHAT_APP_SECRET
WECHAT_MP_TEMPLATE_ID: Optional[str] = None # 模板消息 ID
WECHAT_MP_URL: Optional[str] = None # 模板消息点击跳转链接
WECHAT_MP_MINI_APPID: Optional[str] = None # 跳转小程序 appid(可选)
WECHAT_MP_MINI_PAGE: Optional[str] = None # 跳转小程序页面路径(可选)
# 短信通知配置
SMS_PROVIDER: Optional[str] = None # 短信服务商: aliyun / tencent
# 阿里云短信
ALIYUN_SMS_ACCESS_KEY_ID: Optional[str] = None
ALIYUN_SMS_ACCESS_KEY_SECRET: Optional[str] = None
ALIYUN_SMS_SIGN_NAME: Optional[str] = None # 短信签名
ALIYUN_SMS_TEMPLATE_CODE: Optional[str] = None # 短信模板编号
# 腾讯云短信
TENCENT_SMS_SECRET_ID: Optional[str] = None
TENCENT_SMS_SECRET_KEY: Optional[str] = None
TENCENT_SMS_SDK_APP_ID: Optional[str] = None # 短信应用 SDKAppID
TENCENT_SMS_SIGN_NAME: Optional[str] = None # 短信签名
TENCENT_SMS_TEMPLATE_ID: Optional[str] = None # 短信模板 ID
# 系统通知用户(用于聊天通知渠道的发送者)
SYSTEM_NOTIFY_USER_ID: Optional[str] = None
# 向量数据库配置(Qdrant
VECTOR_STORE_TYPE: str = "qdrant" # 向量存储类型
QDRANT_HOST: str = "pro.fuadmin.cn"
QDRANT_PORT: int = 6333
QDRANT_GRPC_PORT: int = 6334
QDRANT_API_KEY: Optional[str] = None
QDRANT_PREFER_GRPC: bool = False
# 语音,ocr模型key(优先从数据库 LLMProvider qwen 提供商获取,此处作为兜底)
DASHSCOPE_API_KEY: Optional[str] = None
model_config = SettingsConfigDict(
env_file=f"env/{os.getenv('ENV', 'dev')}.env",
env_file_encoding="utf-8",
case_sensitive=True,
extra="ignore",
)
@model_validator(mode="after")
def build_urls(self) -> "Settings":
"""自动拼接DATABASE_URL和REDIS_URL"""
if not self.DATABASE_URL:
if self.DB_TYPE == "mysql":
self.DATABASE_URL = (
f"mysql+aiomysql://{self.DB_USER}:{self.DB_PASSWORD}"
f"@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}?charset=utf8mb4"
)
elif self.DB_TYPE == "sqlserver":
self.DATABASE_URL = (
f"mssql+aioodbc://{self.DB_USER}:{self.DB_PASSWORD}"
f"@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
f"?driver=ODBC+Driver+18+for+SQL+Server&TrustServerCertificate=yes"
)
else:
self.DATABASE_URL = (
f"postgresql+asyncpg://{self.DB_USER}:{self.DB_PASSWORD}"
f"@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
)
if not self.REDIS_URL:
if self.REDIS_PASSWORD:
self.REDIS_URL = (
f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
)
else:
self.REDIS_URL = f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
return self
def get_settings() -> Settings:
"""根据环境变量加载对应的配置文件"""
env = os.getenv("ENV", "dev")
return Settings(_env_file=f"env/{env}.env")
settings = get_settings()
+461
View File
@@ -0,0 +1,461 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
配置管理器 - 三级配置获取工具
优先级: Redis 缓存 → 数据库 → env 配置文件
使用方式:
from app.config_manager import config_manager
# 获取单个配置
value = await config_manager.get("notify_email", "smtp_host")
# 获取整个分组
email_config = await config_manager.get_group("notify_email")
# 设置配置(同时写入数据库和 Redis)
await config_manager.set("notify_email", "smtp_host", "smtp.qq.com", db=db)
"""
import json
import logging
from typing import Any, Dict, List, Optional
from utils.redis import CacheManager
logger = logging.getLogger(__name__)
# Redis 缓存过期时间(秒),1 小时
CONFIG_CACHE_EXPIRE = 3600
# 配置分组到 env 配置文件字段的映射
# 格式: { "config_group": { "config_key": "SETTINGS_ATTR_NAME" } }
GROUP_ENV_MAPPING: Dict[str, Dict[str, str]] = {
# ===== OAuth SSO 配置 =====
"oauth_gitee": {
"client_id": "GITEE_CLIENT_ID",
"client_secret": "GITEE_CLIENT_SECRET",
"redirect_uri": "GITEE_REDIRECT_URI",
},
"oauth_github": {
"client_id": "GITHUB_CLIENT_ID",
"client_secret": "GITHUB_CLIENT_SECRET",
"redirect_uri": "GITHUB_REDIRECT_URI",
},
"oauth_qq": {
"app_id": "QQ_APP_ID",
"app_key": "QQ_APP_KEY",
"redirect_uri": "QQ_REDIRECT_URI",
},
"oauth_google": {
"client_id": "GOOGLE_CLIENT_ID",
"client_secret": "GOOGLE_CLIENT_SECRET",
"redirect_uri": "GOOGLE_REDIRECT_URI",
},
"oauth_wechat": {
"app_id": "WECHAT_APP_ID",
"app_secret": "WECHAT_APP_SECRET",
"redirect_uri": "WECHAT_REDIRECT_URI",
},
"oauth_microsoft": {
"client_id": "MICROSOFT_CLIENT_ID",
"client_secret": "MICROSOFT_CLIENT_SECRET",
"redirect_uri": "MICROSOFT_REDIRECT_URI",
},
"oauth_dingtalk": {
"app_id": "DINGTALK_APP_ID",
"app_secret": "DINGTALK_APP_SECRET",
"redirect_uri": "DINGTALK_REDIRECT_URI",
"h5_redirect_uri": "DINGTALK_H5_REDIRECT_URI",
},
"oauth_feishu": {
"app_id": "FEISHU_APP_ID",
"app_secret": "FEISHU_APP_SECRET",
"redirect_uri": "FEISHU_REDIRECT_URI",
"h5_redirect_uri": "FEISHU_H5_REDIRECT_URI",
},
"oauth_wecom": {
"corp_id": "WECOM_CORP_ID",
"agent_id": "WECOM_AGENT_ID",
"app_secret": "WECOM_APP_SECRET",
"redirect_uri": "WECOM_REDIRECT_URI",
"h5_redirect_uri": "WECOM_H5_REDIRECT_URI",
},
# ===== 消息通知配置 =====
"notify_email": {
"smtp_host": "SMTP_HOST",
"smtp_port": "SMTP_PORT",
"smtp_user": "SMTP_USER",
"smtp_password": "SMTP_PASSWORD",
"smtp_use_tls": "SMTP_USE_TLS",
"smtp_from_name": "SMTP_FROM_NAME",
"smtp_from_email": "SMTP_FROM_EMAIL",
},
"notify_dingtalk": {
"webhook_url": "DINGTALK_WEBHOOK_URL",
"webhook_secret": "DINGTALK_WEBHOOK_SECRET",
"agent_id": "DINGTALK_AGENT_ID",
"corp_id": "DINGTALK_CORP_ID",
"todo_pc_url": "DINGTALK_TODO_PC_URL",
"todo_app_url": "DINGTALK_TODO_APP_URL",
},
"notify_feishu": {
"webhook_url": "FEISHU_WEBHOOK_URL",
"webhook_secret": "FEISHU_WEBHOOK_SECRET",
},
"notify_wecom": {
"webhook_url": "WECOM_WEBHOOK_URL",
},
"notify_wechat_mp": {
"template_id": "WECHAT_MP_TEMPLATE_ID",
"url": "WECHAT_MP_URL",
"mini_appid": "WECHAT_MP_MINI_APPID",
"mini_page": "WECHAT_MP_MINI_PAGE",
},
# ===== 钉钉组织架构同步配置 =====
"sync_dingtalk": {
"corp_id": "DINGTALK_CORP_ID",
"app_key": "DINGTALK_APP_KEY",
"app_secret": "DINGTALK_APP_SECRET",
"sync_dept_id": "",
"sync_root_dept_id": "",
"enable_dept_event": "",
"enable_user_event": "",
"callback_token": "",
"callback_aes_key": "",
"callback_url": "",
},
# ===== 企业微信组织架构同步配置 =====
"sync_wecom": {
"corp_id": "WECOM_SYNC_CORP_ID",
"corp_secret": "WECOM_SYNC_CORP_SECRET",
"sync_dept_id": "",
"sync_root_dept_id": "",
"enable_dept_event": "",
"enable_user_event": "",
"callback_token": "",
"callback_aes_key": "",
"callback_url": "",
},
# ===== 飞书组织架构同步配置 =====
"sync_feishu": {
"app_id": "FEISHU_SYNC_APP_ID",
"app_secret": "FEISHU_SYNC_APP_SECRET",
"sync_dept_id": "",
"sync_root_dept_id": "",
"enable_dept_event": "",
"enable_user_event": "",
"encrypt_key": "",
"verification_token": "",
"callback_url": "",
},
"notify_sms": {
"provider": "SMS_PROVIDER",
"aliyun_access_key_id": "ALIYUN_SMS_ACCESS_KEY_ID",
"aliyun_access_key_secret": "ALIYUN_SMS_ACCESS_KEY_SECRET",
"aliyun_sign_name": "ALIYUN_SMS_SIGN_NAME",
"aliyun_template_code": "ALIYUN_SMS_TEMPLATE_CODE",
"tencent_secret_id": "TENCENT_SMS_SECRET_ID",
"tencent_secret_key": "TENCENT_SMS_SECRET_KEY",
"tencent_sdk_app_id": "TENCENT_SMS_SDK_APP_ID",
"tencent_sign_name": "TENCENT_SMS_SIGN_NAME",
"tencent_template_id": "TENCENT_SMS_TEMPLATE_ID",
},
}
# 敏感字段列表(API 返回时脱敏)
SECRET_KEYS = {
"client_secret", "app_secret", "app_key", "corp_secret",
"smtp_password", "webhook_secret",
"aliyun_access_key_secret", "tencent_secret_key",
"callback_token", "callback_aes_key",
"encrypt_key", "verification_token",
}
class ConfigManager:
"""
三级配置管理器
获取优先级: Redis → 数据库 → env 配置文件
写入: 同时写入数据库 + Redis
"""
def __init__(self):
self._cache = CacheManager(prefix="sys_config:")
# ========== 读取 ==========
async def get(self, group: str, key: str) -> Optional[str]:
"""
获取单个配置值
:param group: 配置分组
:param key: 配置键
:return: 配置值,三级都没有返回 None
"""
# 1. Redis
cache_key = f"{group}:{key}"
value = await self._cache.get(cache_key)
if value is not None:
return value
# 2. 数据库
value = await self._get_from_db(group, key)
if value is not None:
await self._cache.set(cache_key, value, expire=CONFIG_CACHE_EXPIRE)
return value
# 3. env 配置文件
value = self._get_from_env(group, key)
if value is not None:
# 回写到 Redis 缓存
await self._cache.set(cache_key, value, expire=CONFIG_CACHE_EXPIRE)
return value
async def get_group(self, group: str) -> Dict[str, Any]:
"""
获取整个分组的配置
:param group: 配置分组
:return: {key: value} 字典
"""
# 先尝试从 Redis 获取整个分组
group_cache_key = f"_group_:{group}"
cached = await self._cache.get(group_cache_key)
if cached is not None and isinstance(cached, dict):
return cached
# 从数据库获取该分组所有配置
db_configs = await self._get_group_from_db(group)
# 获取该分组的 env 映射
env_mapping = GROUP_ENV_MAPPING.get(group, {})
# 合并: 数据库值优先,缺失的用 env 补充
result = {}
for key in env_mapping:
if key in db_configs and db_configs[key] is not None and db_configs[key] != "":
result[key] = db_configs[key]
else:
env_value = self._get_from_env(group, key)
result[key] = env_value
# 数据库中可能有 env_mapping 之外的自定义 key
for key, value in db_configs.items():
if key not in result:
result[key] = value
# 缓存整个分组
await self._cache.set(group_cache_key, result, expire=CONFIG_CACHE_EXPIRE)
return result
async def get_all_groups(self) -> Dict[str, Dict[str, Any]]:
"""获取所有分组的配置"""
result = {}
for group in GROUP_ENV_MAPPING:
result[group] = await self.get_group(group)
return result
# ========== 写入 ==========
async def set(self, group: str, key: str, value: Optional[str], db_session=None) -> None:
"""
设置单个配置值(写入数据库 + 更新 Redis)
:param group: 配置分组
:param key: 配置键
:param value: 配置值
:param db_session: 数据库会话(可选,不传则自动创建)
"""
await self._set_to_db(group, key, value, db_session)
# 更新 Redis 单个 key 缓存
cache_key = f"{group}:{key}"
if value is not None:
await self._cache.set(cache_key, value, expire=CONFIG_CACHE_EXPIRE)
else:
await self._cache.delete(cache_key)
# 清除分组缓存(下次 get_group 会重新加载)
await self._invalidate_group_cache(group)
async def set_group(self, group: str, configs: Dict[str, Optional[str]], db_session=None) -> None:
"""
批量设置分组配置
:param group: 配置分组
:param configs: {key: value} 字典
:param db_session: 数据库会话
"""
for key, value in configs.items():
await self._set_to_db(group, key, value, db_session)
cache_key = f"{group}:{key}"
if value is not None:
await self._cache.set(cache_key, value, expire=CONFIG_CACHE_EXPIRE)
else:
await self._cache.delete(cache_key)
# 清除分组缓存
await self._invalidate_group_cache(group)
# ========== 缓存管理 ==========
async def invalidate(self, group: str, key: str) -> None:
"""清除单个配置的缓存"""
await self._cache.delete(f"{group}:{key}")
await self._invalidate_group_cache(group)
async def invalidate_group(self, group: str) -> None:
"""清除整个分组的缓存"""
env_mapping = GROUP_ENV_MAPPING.get(group, {})
for key in env_mapping:
await self._cache.delete(f"{group}:{key}")
await self._invalidate_group_cache(group)
async def invalidate_all(self) -> None:
"""清除所有配置缓存"""
await self._cache.delete_pattern("*")
async def warmup(self) -> None:
"""
预热: 启动时清除旧缓存,然后将数据库配置加载到 Redis
"""
logger.info("开始预热系统配置到 Redis...")
try:
await self.invalidate_all()
for group in GROUP_ENV_MAPPING:
await self.get_group(group)
logger.info("系统配置预热完成")
except Exception as e:
logger.warning(f"系统配置预热失败(将使用 env 配置文件兜底): {e}")
# ========== 内部方法 ==========
async def _invalidate_group_cache(self, group: str) -> None:
"""清除分组级缓存"""
await self._cache.delete(f"_group_:{group}")
async def _get_from_db(self, group: str, key: str) -> Optional[str]:
"""从数据库获取配置"""
try:
from app.database import AsyncSessionLocal
from core.system_config.model import SystemConfig
from sqlalchemy import select
async with AsyncSessionLocal() as db:
result = await db.execute(
select(SystemConfig.config_value).where(
SystemConfig.config_group == group,
SystemConfig.config_key == key,
SystemConfig.status == True, # noqa: E712
SystemConfig.is_deleted == False, # noqa: E712
)
)
row = result.scalar_one_or_none()
return row
except Exception as e:
logger.debug(f"从数据库获取配置失败 {group}.{key}: {e}")
return None
async def _get_group_from_db(self, group: str) -> Dict[str, str]:
"""从数据库获取整个分组"""
try:
from app.database import AsyncSessionLocal
from core.system_config.model import SystemConfig
from sqlalchemy import select
async with AsyncSessionLocal() as db:
result = await db.execute(
select(SystemConfig.config_key, SystemConfig.config_value).where(
SystemConfig.config_group == group,
SystemConfig.status == True, # noqa: E712
SystemConfig.is_deleted == False, # noqa: E712
)
)
return {row[0]: row[1] for row in result.all()}
except Exception as e:
logger.debug(f"从数据库获取分组配置失败 {group}: {e}")
return {}
async def _set_to_db(self, group: str, key: str, value: Optional[str], db_session=None) -> None:
"""写入数据库(upsert"""
from core.system_config.model import SystemConfig
from sqlalchemy import select
async def _do_upsert(db):
result = await db.execute(
select(SystemConfig).where(
SystemConfig.config_group == group,
SystemConfig.config_key == key,
SystemConfig.is_deleted == False, # noqa: E712
)
)
config = result.scalar_one_or_none()
if config:
config.config_value = value
else:
config = SystemConfig(
config_group=group,
config_key=key,
config_value=value,
is_secret=key in SECRET_KEYS,
status=True,
)
db.add(config)
await db.commit()
if db_session:
await _do_upsert(db_session)
else:
from app.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
await _do_upsert(db)
def _get_from_env(self, group: str, key: str) -> Optional[str]:
"""从 env 配置文件获取"""
from app.config import settings
env_mapping = GROUP_ENV_MAPPING.get(group, {})
attr_name = env_mapping.get(key)
if not attr_name:
return None
value = getattr(settings, attr_name, None)
if value is None:
return None
return str(value)
# ========== 工具方法 ==========
@staticmethod
def mask_value(value: Optional[str]) -> Optional[str]:
"""脱敏处理"""
if not value:
return value
if len(value) <= 6:
return "***"
return value[:3] + "***" + value[-3:]
@staticmethod
def get_group_list() -> List[Dict[str, str]]:
"""获取所有配置分组定义"""
groups = []
for group_key in GROUP_ENV_MAPPING:
groups.append({
"key": group_key,
"fields": list(GROUP_ENV_MAPPING[group_key].keys()),
})
return groups
@staticmethod
def is_secret_key(key: str) -> bool:
"""判断是否为敏感字段"""
return key in SECRET_KEYS
# 全局单例
config_manager = ConfigManager()
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
数据权限工具函数
提供独立的数据权限过滤功能,供不继承 BaseService 的模块使用
"""
from typing import Any, Dict, List, Optional
from sqlalchemy import and_, or_
from sqlalchemy.ext.asyncio import AsyncSession
from utils.context import get_current_user_info_from_context
async def get_data_scope_filter(
db: AsyncSession,
resource_type: str,
user_info: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
获取数据权限过滤条件
:param db: 数据库会话
:param resource_type: 资源类型(如 "page", "data_source" 等)
:param user_info: 用户信息字典,如果不传则自动从上下文获取
:return: 数据权限过滤条件字典
"""
# 如果没有传入用户信息,从上下文获取
if user_info is None:
user_info = get_current_user_info_from_context()
# 如果没有用户信息,返回全部数据
if not user_info:
return {
'filter_type': 'all',
'scope': 0,
'user_id': None,
'dept_id': None,
'dept_ids': None
}
# 超级管理员:全部数据
if user_info.get("is_superuser", False):
return {
'filter_type': 'all',
'scope': 0,
'user_id': None,
'dept_id': None,
'dept_ids': None
}
# 如果没有定义资源类型,默认全部数据
if not resource_type:
return {
'filter_type': 'all',
'scope': 0,
'user_id': None,
'dept_id': None,
'dept_ids': None
}
# 使用资源类型绑定的数据权限
from core.resource_scope.scope_permission.service import ResourceDataScopeConfigService
# 查询资源数据权限配置(支持多角色)
config_dict = await ResourceDataScopeConfigService.get_resource_data_scope(
db=db,
role_ids=user_info.get("role_ids", []),
resource_type=resource_type,
is_superuser=user_info.get("is_superuser", False)
)
# 填充用户和部门信息
if config_dict['filter_type'] == 'self':
config_dict['user_id'] = user_info.get("user_id")
elif config_dict['filter_type'] == 'dept':
config_dict['dept_id'] = user_info.get("dept_id")
elif config_dict['filter_type'] == 'dept_and_children':
# 获取部门树
if user_info.get("dept_id"):
from core.auth.dept.service import DeptService
dept_ids = await DeptService.get_dept_and_children_ids(db, user_info.get("dept_id"))
config_dict['dept_ids'] = dept_ids
else:
config_dict['dept_ids'] = []
return config_dict
def apply_data_scope_to_conditions(
model,
data_scope_filter: Dict[str, Any],
dept_field: str = "sys_dept_id",
user_field: str = "sys_creator_id"
) -> List[Any]:
"""
根据数据权限过滤条件生成 SQLAlchemy 条件列表
注意:当 sys_creator_id 或 sys_dept_id 为空时,记录对所有人可见
:param model: SQLAlchemy 模型类
:param data_scope_filter: 数据权限过滤条件
:param dept_field: 部门字段名
:param user_field: 用户字段名
:return: SQLAlchemy 条件列表
"""
conditions = []
filter_type = data_scope_filter.get('filter_type')
# 全部数据:不添加过滤条件
if filter_type == 'all':
return conditions
# 仅本人数据:匹配创建人 OR 创建人为空(对所有人可见)
if filter_type == 'self':
if hasattr(model, user_field):
user_field_obj = getattr(model, user_field)
conditions.append(
or_(
user_field_obj == data_scope_filter['user_id'],
user_field_obj.is_(None) # 创建人为空时所有人可见
)
)
return conditions
# 本部门数据:匹配部门 OR 部门为空(对所有人可见)
if filter_type == 'dept':
if hasattr(model, dept_field):
dept_field_obj = getattr(model, dept_field)
conditions.append(
or_(
dept_field_obj == data_scope_filter['dept_id'],
dept_field_obj.is_(None) # 部门为空时所有人可见
)
)
return conditions
# 本部门及下级部门数据 / 自定义数据
if filter_type in ('dept_and_children', 'custom'):
if hasattr(model, dept_field):
dept_field_obj = getattr(model, dept_field)
dept_ids = data_scope_filter.get('dept_ids', [])
if dept_ids:
conditions.append(
or_(
dept_field_obj.in_(dept_ids),
dept_field_obj.is_(None) # 部门为空时所有人可见
)
)
else:
# 如果没有部门ID,只返回部门为空的记录
conditions.append(dept_field_obj.is_(None))
return conditions
return conditions
+152
View File
@@ -0,0 +1,152 @@
import logging
from contextlib import asynccontextmanager
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from sqlalchemy import event
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import declarative_base
from app.config import settings
logger = logging.getLogger(__name__)
def _get_utc_offset_str(tz_name: str) -> str:
"""将 IANA 时区名转为 MySQL 兼容的 UTC 偏移字符串,如 '+08:00'"""
now = datetime.now(ZoneInfo(tz_name))
offset = now.utcoffset() or timedelta()
total_seconds = int(offset.total_seconds())
sign = "+" if total_seconds >= 0 else "-"
hours, remainder = divmod(abs(total_seconds), 3600)
minutes = remainder // 60
return f"{sign}{hours:02d}:{minutes:02d}"
def _build_connect_args() -> dict:
"""根据数据库类型构建 connect_args,在连接级别设置时区"""
if settings.DB_TYPE == "postgresql":
return {"server_settings": {"timezone": settings.TIMEZONE}}
return {}
# 创建异步引擎
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
connect_args=_build_connect_args(),
pool_size=5, # 连接池常驻连接数(减少以避免连接耗尽)
max_overflow=10, # 超出pool_size后可创建的连接数(总计最多15)
pool_timeout=30, # 获取连接的超时时间(秒)
pool_recycle=600, # 连接回收时间(秒),更积极地回收空闲连接
pool_pre_ping=True, # 使用前检查连接是否有效,自动重连
pool_reset_on_return="rollback", # 连接归还时重置状态
)
# MySQL / SQL Server 通过 connect 事件设置会话时区
if settings.DB_TYPE in ("mysql", "sqlserver"):
@event.listens_for(engine.sync_engine, "connect")
def _set_session_timezone(dbapi_conn, connection_rec):
offset_str = _get_utc_offset_str(settings.TIMEZONE)
cursor = dbapi_conn.cursor()
try:
if settings.DB_TYPE == "mysql":
cursor.execute(f"SET time_zone = '{offset_str}'")
elif settings.DB_TYPE == "sqlserver":
pass # SQL Server 不支持会话级时区设置,需在应用层处理
finally:
cursor.close()
logger.debug(f"[DB] Session timezone set to {settings.TIMEZONE} ({offset_str})")
# 连接池监控事件
@event.listens_for(engine.sync_engine, "checkout")
def _on_checkout(dbapi_conn, connection_rec, connection_proxy):
pool = engine.sync_engine.pool
logger.debug(
f"[Pool] checkout: size={pool.size()}, checkedin={pool.checkedin()}, "
f"checkedout={pool.checkedout()}, overflow={pool.overflow()}"
)
@event.listens_for(engine.sync_engine, "checkin")
def _on_checkin(dbapi_conn, connection_rec):
pool = engine.sync_engine.pool
logger.debug(
f"[Pool] checkin: size={pool.size()}, checkedin={pool.checkedin()}, "
f"checkedout={pool.checkedout()}, overflow={pool.overflow()}"
)
logger.info(f"[DB] Engine created with timezone: {settings.TIMEZONE} (db_type={settings.DB_TYPE})")
# 创建异步会话工厂
AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
# 声明基类
Base = declarative_base()
async def get_db() -> AsyncSession:
"""获取数据库会话的依赖函数"""
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
async def get_db_transaction() -> AsyncSession:
"""
获取带事务的数据库会话依赖函数
使用方式:
@router.post("/")
async def create_something(db: AsyncSession = Depends(get_db_transaction)):
# 所有数据库操作在同一事务中
# 如果发生异常,自动回滚
# 如果成功完成,自动提交
...
注意:使用此依赖时,Service层的方法不应调用commit()
因为事务会在API结束时统一提交或回滚。
可以使用BaseService的_no_commit版本方法,或手动控制。
"""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
@asynccontextmanager
async def transaction(db: AsyncSession):
"""
事务上下文管理器,用于在API中包装多个操作
使用方式:
@router.post("/")
async def create_something(db: AsyncSession = Depends(get_db)):
async with transaction(db):
# 所有操作在同一事务中
await SomeService.create_no_commit(db, data1)
await OtherService.create_no_commit(db, data2)
# 如果发生异常,自动回滚
# 如果成功完成,自动提交
"""
try:
yield db
await db.commit()
except Exception:
await db.rollback()
raise
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
数据库兼容层工具
提供跨数据库(PostgreSQL、MySQL、SQL Server)的 JSON 操作兼容函数
"""
from typing import Any, List
from sqlalchemy import func, literal, text
from sqlalchemy.sql import ColumnElement
def get_db_type() -> str:
"""
获取当前数据库类型
Returns:
数据库类型: 'postgresql', 'mysql''sqlserver'
"""
from app.config import settings
return settings.DB_TYPE
def json_contains(column: ColumnElement, value: Any) -> ColumnElement:
"""
JSON 数组包含检查(跨数据库兼容)
检查 JSON 数组列是否包含指定值
PostgreSQL: 使用 JSONB 的 @> 操作符
MySQL: 使用 JSON_CONTAINS 函数
SQL Server: 使用 OPENJSON + EXISTS 子查询
Args:
column: JSON 类型的列
value: 要检查的值(会被转换为 JSON 数组)
Returns:
SQLAlchemy 条件表达式
Example:
# 检查 target_ids 是否包含 "user123"
json_contains(Announcement.target_ids, "user123")
"""
db_type = get_db_type()
if db_type == "mysql":
# MySQL: JSON_CONTAINS(column, JSON_ARRAY(value))
import json
json_value = json.dumps([value] if not isinstance(value, list) else value)
return func.json_contains(column, json_value)
elif db_type == "sqlserver":
# SQL Server: 使用 LIKE 进行简单匹配(JSON 数组中包含元素)
# 注意: 这是简化实现,适用于简单字符串值
import json
search_value = json.dumps(value)
return column.like(f'%{search_value}%')
else:
# PostgreSQL: cast to JSONB and use contains
from sqlalchemy import cast
from sqlalchemy.dialects.postgresql import JSONB
target_value = [value] if not isinstance(value, list) else value
return cast(column, JSONB).contains(cast(target_value, JSONB))
def json_extract(column: ColumnElement, key: str) -> ColumnElement:
"""
从 JSON 对象中提取值(跨数据库兼容)
PostgreSQL: 使用 JSONB 的 ->> 操作符
MySQL: 使用 JSON_EXTRACT 和 JSON_UNQUOTE 函数
SQL Server: 使用 JSON_VALUE 函数
Args:
column: JSON 类型的列
key: 要提取的键名
Returns:
提取的值(作为文本)
Example:
# 提取 extra_metadata 中的 "author" 字段
json_extract(KnowledgeSegment.extra_metadata, "author")
"""
db_type = get_db_type()
if db_type == "mysql":
# MySQL: JSON_UNQUOTE(JSON_EXTRACT(column, '$.key'))
return func.json_unquote(func.json_extract(column, f"$.{key}"))
elif db_type == "sqlserver":
# SQL Server: JSON_VALUE(column, '$.key')
return func.json_value(column, f"$.{key}")
else:
# PostgreSQL: cast to JSONB and use ->> operator
from sqlalchemy import cast
from sqlalchemy.dialects.postgresql import JSONB
return cast(column, JSONB)[key].astext
def json_has_key(column: ColumnElement, key: str) -> ColumnElement:
"""
检查 JSON 对象是否包含指定键(跨数据库兼容)
PostgreSQL: 使用 JSONB 的 ? 操作符
MySQL: 使用 JSON_CONTAINS_PATH 函数
SQL Server: 使用 JSON_VALUE IS NOT NULL
Args:
column: JSON 类型的列
key: 要检查的键名
Returns:
SQLAlchemy 条件表达式
Example:
# 检查 extra_metadata 是否包含 "author"
json_has_key(KnowledgeSegment.extra_metadata, "author")
"""
db_type = get_db_type()
if db_type == "mysql":
# MySQL: JSON_CONTAINS_PATH(column, 'one', '$.key')
return func.json_contains_path(column, "one", f"$.{key}")
elif db_type == "sqlserver":
# SQL Server: JSON_VALUE(column, '$.key') IS NOT NULL
return func.json_value(column, f"$.{key}").isnot(None)
else:
# PostgreSQL: cast to JSONB and use has_key
from sqlalchemy import cast
from sqlalchemy.dialects.postgresql import JSONB
return cast(column, JSONB).has_key(key)
def json_array_contains_any(column: ColumnElement, values: List[Any]) -> List[ColumnElement]:
"""
生成多个 JSON 数组包含检查条件(用于 OR 组合)
Args:
column: JSON 类型的列
values: 要检查的值列表
Returns:
条件表达式列表,可用于 or_() 组合
Example:
# 检查 target_ids 是否包含任意一个 dept_id
conditions = json_array_contains_any(Announcement.target_ids, dept_ids)
query.where(or_(*conditions))
"""
return [json_contains(column, value) for value in values]
+564
View File
@@ -0,0 +1,564 @@
"""
钉钉通知工具
支持三种通知方式:
1. Webhook 群机器人 - 向钉钉群聊发送消息
2. 工作通知 - 通过企业内部应用向个人发送工作通知
3. 待办任务 - 通过钉钉待办 API 创建/管理第三方待办
"""
import base64
import hashlib
import hmac
import logging
import time
import urllib.parse
from typing import Any, Dict, List, Optional
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
class DingTalkWebhook:
"""钉钉群机器人 Webhook"""
@staticmethod
async def _get_config() -> dict:
"""通过 config_manager 三级获取钉钉 Webhook 配置"""
from app.config_manager import config_manager
group_config = await config_manager.get_group("notify_dingtalk")
return {
"webhook_url": group_config.get("webhook_url") or None,
"webhook_secret": group_config.get("webhook_secret") or None,
}
@staticmethod
def is_configured() -> bool:
"""检查 Webhook 是否已配置(仅检查 env,同步兼容)"""
return bool(settings.DINGTALK_WEBHOOK_URL)
@staticmethod
async def is_configured_async() -> bool:
"""检查 Webhook 是否已配置(通过 config_manager 三级获取)"""
config = await DingTalkWebhook._get_config()
return bool(config["webhook_url"])
@staticmethod
def _sign_with_secret(secret: str) -> Dict[str, str]:
"""使用指定 secret 生成签名参数"""
if not secret:
return {}
timestamp = str(round(time.time() * 1000))
string_to_sign = f"{timestamp}\n{secret}"
hmac_code = hmac.new(
secret.encode("utf-8"),
string_to_sign.encode("utf-8"),
digestmod=hashlib.sha256,
).digest()
sign = urllib.parse.quote_plus(base64.b64encode(hmac_code))
return {"timestamp": timestamp, "sign": sign}
@staticmethod
async def send_text(content: str, at_mobiles: List[str] = None, at_all: bool = False) -> bool:
"""
发送文本消息
Args:
content: 消息内容
at_mobiles: @指定手机号列表
at_all: 是否@所有人
"""
config = await DingTalkWebhook._get_config()
if not config["webhook_url"]:
logger.warning("钉钉 Webhook 未配置,跳过发送")
return False
payload: Dict[str, Any] = {
"msgtype": "text",
"text": {"content": content},
"at": {
"atMobiles": at_mobiles or [],
"isAtAll": at_all,
},
}
return await DingTalkWebhook._post(payload, config)
@staticmethod
async def send_markdown(title: str, text: str, at_mobiles: List[str] = None, at_all: bool = False) -> bool:
"""
发送 Markdown 消息
Args:
title: 消息标题(会话列表中展示)
text: Markdown 格式内容
at_mobiles: @指定手机号列表
at_all: 是否@所有人
"""
config = await DingTalkWebhook._get_config()
if not config["webhook_url"]:
logger.warning("钉钉 Webhook 未配置,跳过发送")
return False
payload: Dict[str, Any] = {
"msgtype": "markdown",
"markdown": {"title": title, "text": text},
"at": {
"atMobiles": at_mobiles or [],
"isAtAll": at_all,
},
}
return await DingTalkWebhook._post(payload, config)
@staticmethod
async def send_action_card(title: str, text: str, single_url: str = "", single_title: str = "查看详情") -> bool:
"""
发送 ActionCard 消息
Args:
title: 消息标题
text: Markdown 格式内容
single_url: 跳转链接
single_title: 按钮文字
"""
config = await DingTalkWebhook._get_config()
if not config["webhook_url"]:
logger.warning("钉钉 Webhook 未配置,跳过发送")
return False
payload: Dict[str, Any] = {
"msgtype": "actionCard",
"actionCard": {
"title": title,
"text": text,
"singleTitle": single_title,
"singleURL": single_url,
},
}
return await DingTalkWebhook._post(payload, config)
@staticmethod
async def _post(payload: Dict[str, Any], config: dict = None) -> bool:
"""发送请求到钉钉 Webhook"""
try:
if config is None:
config = await DingTalkWebhook._get_config()
url = config["webhook_url"]
sign_params = DingTalkWebhook._sign_with_secret(config.get("webhook_secret") or "")
if sign_params:
separator = "&" if "?" in url else "?"
url = f"{url}{separator}timestamp={sign_params['timestamp']}&sign={sign_params['sign']}"
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(url, json=payload)
resp.raise_for_status()
result = resp.json()
if result.get("errcode") == 0:
logger.info("钉钉 Webhook 消息发送成功")
return True
else:
logger.error(f"钉钉 Webhook 发送失败: {result}")
return False
except Exception as e:
logger.error(f"钉钉 Webhook 请求异常: {e}")
return False
class DingTalkWorkNotice:
"""钉钉工作通知(企业内部应用)"""
TOKEN_URL = "https://oapi.dingtalk.com/gettoken"
SEND_URL = "https://oapi.dingtalk.com/topapi/message/corpconversation/asyncsend_v2"
_access_token: Optional[str] = None
_token_expires_at: float = 0
@classmethod
async def _get_config(cls) -> dict:
"""通过 config_manager 三级获取钉钉工作通知配置"""
from app.config_manager import config_manager
group_config = await config_manager.get_group("notify_dingtalk")
return {
"agent_id": group_config.get("agent_id") or None,
"corp_id": group_config.get("corp_id") or None,
"app_id": group_config.get("webhook_url") and None, # webhook_url 不用于工作通知
}
@classmethod
def is_configured(cls) -> bool:
"""检查工作通知是否已配置(仅检查 env,同步兼容)"""
return bool(
settings.DINGTALK_APP_ID
and settings.DINGTALK_APP_SECRET
and settings.DINGTALK_AGENT_ID
)
@classmethod
async def is_configured_async(cls) -> bool:
"""检查工作通知是否已配置(通过 config_manager 三级获取)"""
from app.config_manager import config_manager
group_config = await config_manager.get_group("notify_dingtalk")
# 工作通知需要 agent_id + OAuth 配置中的 app_id/app_secret
agent_id = group_config.get("agent_id") or None
# app_id/app_secret 来自 oauth_dingtalk 分组
oauth_config = await config_manager.get_group("oauth_dingtalk")
app_id = oauth_config.get("app_id") or None
app_secret = oauth_config.get("app_secret") or None
return bool(app_id and app_secret and agent_id)
@classmethod
async def _get_access_token(cls) -> Optional[str]:
"""获取企业内部应用的 access_token(带缓存)"""
now = time.time()
if cls._access_token and now < cls._token_expires_at:
return cls._access_token
from app.config_manager import config_manager
oauth_config = await config_manager.get_group("oauth_dingtalk")
app_id = oauth_config.get("app_id")
app_secret = oauth_config.get("app_secret")
if not (app_id and app_secret):
logger.warning("钉钉 app_id/app_secret 未配置")
return None
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
cls.TOKEN_URL,
params={
"appkey": app_id,
"appsecret": app_secret,
},
)
resp.raise_for_status()
result = resp.json()
if result.get("errcode") == 0:
cls._access_token = result["access_token"]
cls._token_expires_at = now + result.get("expires_in", 7200) - 300
logger.info("钉钉 access_token 获取成功")
return cls._access_token
else:
logger.error(f"获取钉钉 access_token 失败: {result}")
return None
except Exception as e:
logger.error(f"获取钉钉 access_token 异常: {e}")
return None
@classmethod
async def send_text(cls, userid_list: List[str], content: str) -> bool:
"""
发送文本工作通知
Args:
userid_list: 钉钉用户 userId 列表(最多100个)
content: 消息内容
"""
return await cls._send(userid_list, {"msgtype": "text", "text": {"content": content}})
@classmethod
async def send_markdown(cls, userid_list: List[str], title: str, text: str) -> bool:
"""
发送 Markdown 工作通知
Args:
userid_list: 钉钉用户 userId 列表
title: 消息标题
text: Markdown 格式内容
"""
return await cls._send(userid_list, {"msgtype": "markdown", "markdown": {"title": title, "text": text}})
@classmethod
async def send_action_card(
cls,
userid_list: List[str],
title: str,
markdown: str,
single_url: str = "",
single_title: str = "查看详情",
) -> bool:
"""
发送 ActionCard 工作通知
Args:
userid_list: 钉钉用户 userId 列表
title: 消息标题
markdown: Markdown 格式内容
single_url: 跳转链接
single_title: 按钮文字
"""
return await cls._send(userid_list, {
"msgtype": "action_card",
"action_card": {
"title": title,
"markdown": markdown,
"single_title": single_title,
"single_url": single_url,
},
})
@classmethod
async def _send(cls, userid_list: List[str], msg: Dict[str, Any]) -> bool:
"""发送工作通知"""
if not await cls.is_configured_async():
logger.warning("钉钉工作通知未配置,跳过发送")
return False
token = await cls._get_access_token()
if not token:
return False
from app.config_manager import config_manager
group_config = await config_manager.get_group("notify_dingtalk")
agent_id = group_config.get("agent_id") or settings.DINGTALK_AGENT_ID
try:
payload = {
"agent_id": agent_id,
"userid_list": ",".join(userid_list[:100]),
"msg": msg,
}
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
cls.SEND_URL,
params={"access_token": token},
json=payload,
)
resp.raise_for_status()
result = resp.json()
if result.get("errcode") == 0:
logger.info(f"钉钉工作通知发送成功: {len(userid_list)}")
return True
else:
logger.error(f"钉钉工作通知发送失败: {result}")
return False
except Exception as e:
logger.error(f"钉钉工作通知请求异常: {e}")
return False
class DingTalkTodo:
"""钉钉待办任务(v1.0 API"""
BASE_URL = "https://api.dingtalk.com/v1.0/todo/users"
@classmethod
async def is_configured_async(cls) -> bool:
"""检查待办功能是否已配置(复用 OAuth 配置中的 app_id/app_secret"""
from app.config_manager import config_manager
oauth_config = await config_manager.get_group("oauth_dingtalk")
app_id = oauth_config.get("app_id") or None
app_secret = oauth_config.get("app_secret") or None
return bool(app_id and app_secret)
@classmethod
async def _get_headers(cls) -> Optional[Dict[str, str]]:
"""获取请求头(含 access_token"""
token = await DingTalkWorkNotice._get_access_token()
if not token:
return None
return {
"x-acs-dingtalk-access-token": token,
"Content-Type": "application/json",
}
@classmethod
async def _get_detail_urls(cls, link_type: str, link_id: str) -> Dict[str, str]:
"""根据业务类型和ID拼接待办详情页跳转URL"""
from app.config_manager import config_manager
notify_config = await config_manager.get_group("notify_dingtalk")
pc_base = (notify_config.get("todo_pc_url") or "").rstrip("/")
app_base = (notify_config.get("todo_app_url") or "").rstrip("/")
pc_url = ""
app_url = ""
if link_type == "workflow_task" and link_id:
if pc_base:
pc_url = f"{pc_base}/app/workflow_center/workflow/pending?id={link_id}"
if app_base:
app_url = f"{app_base}/pages-workflow/task-detail?taskId={link_id}"
elif link_type == "workflow_instance" and link_id:
if pc_base:
pc_url = f"{pc_base}/app/workflow_center/workflow/initiated?id={link_id}"
if app_base:
app_url = f"{app_base}/pages-workflow/instance-detail?instanceId={link_id}"
return {"pcUrl": pc_url, "appUrl": app_url}
@classmethod
async def create_todo(
cls,
union_id: str,
subject: str,
description: str = "",
executor_ids: List[str] = None,
due_time: int = None,
priority: int = 20,
source_id: str = None,
link_type: str = "",
link_id: str = "",
) -> Optional[str]:
"""
创建钉钉待办任务
Args:
union_id: 创建者的 unionId
subject: 待办标题(最大1024字符)
description: 备注描述(最大4096字符)
executor_ids: 执行者的 unionId 列表
due_time: 截止时间(Unix 时间戳,单位毫秒)
priority: 优先级 10:较低 20:普通 30:较高 40:紧急
source_id: 业务系统唯一标识
link_type: 关联类型(用于拼接详情页URL)
link_id: 关联对象ID
Returns:
钉钉待办 taskId,失败返回 None
"""
headers = await cls._get_headers()
if not headers:
return None
detail_url = await cls._get_detail_urls(link_type, link_id)
payload: Dict[str, Any] = {
"subject": subject[:1024],
"creatorId": union_id,
"priority": priority,
"notifyConfigs": {
"dingNotify": "1",
},
}
if description:
payload["description"] = description[:4096]
if executor_ids:
payload["executorIds"] = executor_ids[:100]
if due_time:
payload["dueTime"] = due_time
if source_id:
payload["sourceId"] = source_id
if detail_url.get("pcUrl") or detail_url.get("appUrl"):
payload["detailUrl"] = {}
if detail_url.get("pcUrl"):
payload["detailUrl"]["pcUrl"] = detail_url["pcUrl"]
if detail_url.get("appUrl"):
payload["detailUrl"]["appUrl"] = detail_url["appUrl"]
try:
url = f"{cls.BASE_URL}/{union_id}/tasks"
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(url, headers=headers, json=payload)
if resp.status_code == 200:
result = resp.json()
task_id = result.get("id")
logger.info(f"钉钉待办创建成功: taskId={task_id}")
return task_id
else:
logger.error(f"钉钉待办创建失败: status={resp.status_code}, body={resp.text}")
return None
except Exception as e:
logger.error(f"钉钉待办创建异常: {e}")
return None
@classmethod
async def update_todo(
cls,
union_id: str,
task_id: str,
subject: str = None,
description: str = None,
done: bool = None,
due_time: int = None,
executor_ids: List[str] = None,
) -> bool:
"""
更新钉钉待办任务
Args:
union_id: 操作者 unionId
task_id: 钉钉待办 taskId
subject: 待办标题
description: 备注描述
done: 完成状态
due_time: 截止时间
executor_ids: 执行者列表
"""
headers = await cls._get_headers()
if not headers:
return False
payload: Dict[str, Any] = {}
if subject is not None:
payload["subject"] = subject[:1024]
if description is not None:
payload["description"] = description[:4096]
if done is not None:
payload["done"] = done
if due_time is not None:
payload["dueTime"] = due_time
if executor_ids is not None:
payload["executorIds"] = executor_ids[:100]
if not payload:
return True
try:
url = f"{cls.BASE_URL}/{union_id}/tasks/{task_id}"
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.put(url, headers=headers, json=payload)
if resp.status_code == 200:
logger.info(f"钉钉待办更新成功: taskId={task_id}")
return True
else:
logger.error(f"钉钉待办更新失败: status={resp.status_code}, body={resp.text}")
return False
except Exception as e:
logger.error(f"钉钉待办更新异常: {e}")
return False
@classmethod
async def complete_todo(cls, union_id: str, task_id: str) -> bool:
"""完成钉钉待办"""
return await cls.update_todo(union_id, task_id, done=True)
@classmethod
async def delete_todo(cls, union_id: str, task_id: str) -> bool:
"""删除钉钉待办"""
headers = await cls._get_headers()
if not headers:
return False
try:
url = f"{cls.BASE_URL}/{union_id}/tasks/{task_id}"
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.delete(url, headers=headers)
if resp.status_code == 200:
logger.info(f"钉钉待办删除成功: taskId={task_id}")
return True
else:
logger.error(f"钉钉待办删除失败: status={resp.status_code}, body={resp.text}")
return False
except Exception as e:
logger.error(f"钉钉待办删除异常: {e}")
return False
def build_dingtalk_markdown(title: str, content: str, app_name: str = None) -> str:
"""
构建钉钉通知的 Markdown 内容
Args:
title: 通知标题
content: 通知内容
app_name: 应用名称
"""
app = app_name or settings.APP_NAME
return f"### {title}\n\n{content}\n\n---\n> 来自 {app}"
+228
View File
@@ -0,0 +1,228 @@
"""
异步邮件发送工具
使用 aiosmtplib 实现异步 SMTP 邮件发送,支持:
- 纯文本邮件
- HTML 邮件
- 批量发送(并发)
- 自动 TLS/SSL
"""
import asyncio
import logging
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Dict, List, Optional
import aiosmtplib
from app.config import settings
logger = logging.getLogger(__name__)
class EmailSender:
"""异步邮件发送器"""
@staticmethod
async def _get_smtp_config() -> dict:
"""
通过 config_manager 三级获取 SMTP 配置(Redis → 数据库 → env)
"""
from app.config_manager import config_manager
group_config = await config_manager.get_group("notify_email")
return {
"host": group_config.get("smtp_host") or None,
"port": int(group_config.get("smtp_port") or settings.SMTP_PORT or 465),
"user": group_config.get("smtp_user") or None,
"password": group_config.get("smtp_password") or None,
"use_tls": str(group_config.get("smtp_use_tls", "True")).lower() in ("true", "1", "yes"),
"from_name": group_config.get("smtp_from_name") or settings.APP_NAME,
"from_email": group_config.get("smtp_from_email") or group_config.get("smtp_user") or None,
}
@staticmethod
def is_configured() -> bool:
"""检查 SMTP 是否已配置(仅检查 env,同步兼容)"""
return bool(settings.SMTP_HOST and settings.SMTP_USER and settings.SMTP_PASSWORD)
@staticmethod
async def is_configured_async() -> bool:
"""检查 SMTP 是否已配置(通过 config_manager 三级获取)"""
config = await EmailSender._get_smtp_config()
return bool(config["host"] and config["user"] and config["password"])
@staticmethod
def _build_from_address(config: dict) -> str:
"""根据配置构建发件人地址"""
from_email = config["from_email"] or config["user"]
from_name = config["from_name"] or settings.APP_NAME
return f"{from_name} <{from_email}>"
@staticmethod
def _get_from_address() -> str:
"""获取发件人地址(同步兼容,仅读 env)"""
from_email = settings.SMTP_FROM_EMAIL or settings.SMTP_USER
from_name = settings.SMTP_FROM_NAME or settings.APP_NAME
return f"{from_name} <{from_email}>"
@staticmethod
async def send(
to_email: str,
subject: str,
content: str,
html: bool = False,
) -> bool:
"""
发送单封邮件
Args:
to_email: 收件人邮箱
subject: 邮件主题
content: 邮件内容(纯文本或 HTML)
html: 是否为 HTML 内容
Returns:
是否发送成功
"""
config = await EmailSender._get_smtp_config()
if not (config["host"] and config["user"] and config["password"]):
logger.warning("SMTP 未配置,跳过邮件发送")
return False
try:
msg = MIMEMultipart("alternative")
msg["From"] = EmailSender._build_from_address(config)
msg["To"] = to_email
msg["Subject"] = subject
if html:
msg.attach(MIMEText(content, "html", "utf-8"))
else:
msg.attach(MIMEText(content, "plain", "utf-8"))
# sender 必须使用 smtp_user,部分邮箱(如QQ)要求 MAIL FROM 与认证用户一致
sender = config["user"]
if config["use_tls"]:
await aiosmtplib.send(
msg,
sender=sender,
hostname=config["host"],
port=config["port"],
username=config["user"],
password=config["password"],
use_tls=True,
)
else:
await aiosmtplib.send(
msg,
sender=sender,
hostname=config["host"],
port=config["port"],
username=config["user"],
password=config["password"],
start_tls=True,
)
logger.info(f"邮件发送成功: {to_email}")
return True
except Exception as e:
logger.error(f"邮件发送失败 [{to_email}]: {e}")
return False
@staticmethod
async def send_batch(
to_emails: List[str],
subject: str,
content: str,
html: bool = False,
) -> Dict[str, bool]:
"""
批量发送邮件(并发)
Args:
to_emails: 收件人邮箱列表
subject: 邮件主题
content: 邮件内容
html: 是否为 HTML 内容
Returns:
每个邮箱的发送结果
"""
config = await EmailSender._get_smtp_config()
if not (config["host"] and config["user"] and config["password"]):
logger.warning("SMTP 未配置,跳过批量邮件发送")
return {email: False for email in to_emails}
tasks = [
EmailSender.send(email, subject, content, html)
for email in to_emails
]
results = await asyncio.gather(*tasks, return_exceptions=True)
return {
email: (result is True)
for email, result in zip(to_emails, results)
}
@staticmethod
def build_notification_html(title: str, content: str, app_name: Optional[str] = None) -> str:
"""
构建通知邮件的 HTML 模板
Args:
title: 通知标题
content: 通知内容
app_name: 应用名称
Returns:
HTML 字符串
"""
app = app_name or settings.APP_NAME
return f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin:0;padding:0;background-color:#f0f4f8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="padding:40px 20px;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.06);">
<!-- Header -->
<tr>
<td style="background:linear-gradient(135deg,#1a73e8 0%,#0d47a1 100%);padding:32px 40px;">
<h1 style="margin:0;color:#ffffff;font-size:22px;font-weight:700;letter-spacing:0.5px;">{app}</h1>
</td>
</tr>
<!-- Accent line -->
<tr>
<td style="height:3px;background:linear-gradient(90deg,#42a5f5,#1a73e8,#0d47a1);"></td>
</tr>
<!-- Body -->
<tr>
<td style="padding:36px 40px 32px;">
<h2 style="margin:0 0 16px;color:#1a1a1a;font-size:18px;font-weight:600;">{title}</h2>
<div style="color:#4a4a4a;font-size:14px;line-height:1.8;word-break:break-word;">{content}</div>
</td>
</tr>
<!-- Divider -->
<tr>
<td style="padding:0 40px;">
<div style="border-top:1px solid #e8edf2;"></div>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding:20px 40px 24px;">
<p style="margin:0;color:#9e9e9e;font-size:12px;text-align:center;line-height:1.6;">
This email was sent by {app}. Please do not reply directly.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>"""
+421
View File
@@ -0,0 +1,421 @@
"""
飞书通知工具
支持两种通知方式:
1. Webhook 群机器人 - 向飞书群聊发送消息
2. 应用消息 - 通过企业自建应用向个人发送消息
"""
import base64
import hashlib
import hmac
import logging
import time
from typing import Any, Dict, List, Optional
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
class FeishuWebhook:
"""飞书群机器人 Webhook"""
@staticmethod
async def _get_config() -> dict:
"""通过 config_manager 三级获取飞书 Webhook 配置"""
from app.config_manager import config_manager
group_config = await config_manager.get_group("notify_feishu")
return {
"webhook_url": group_config.get("webhook_url") or None,
"webhook_secret": group_config.get("webhook_secret") or None,
}
@staticmethod
def is_configured() -> bool:
"""检查 Webhook 是否已配置(仅检查 env,同步兼容)"""
return bool(settings.FEISHU_WEBHOOK_URL)
@staticmethod
async def is_configured_async() -> bool:
"""检查 Webhook 是否已配置(通过 config_manager 三级获取)"""
config = await FeishuWebhook._get_config()
return bool(config["webhook_url"])
@staticmethod
def _sign(timestamp: str, secret: str = None) -> str:
"""生成签名"""
if not secret:
return ""
string_to_sign = f"{timestamp}\n{secret}"
hmac_code = hmac.new(
string_to_sign.encode("utf-8"),
digestmod=hashlib.sha256,
).digest()
return base64.b64encode(hmac_code).decode("utf-8")
@staticmethod
async def send_text(text: str) -> bool:
"""
发送文本消息
Args:
text: 消息内容
"""
config = await FeishuWebhook._get_config()
if not config["webhook_url"]:
logger.warning("飞书 Webhook 未配置,跳过发送")
return False
payload: Dict[str, Any] = {
"msg_type": "text",
"content": {"text": text},
}
return await FeishuWebhook._post(payload, config)
@staticmethod
async def send_rich_text(title: str, content_lines: List[List[Dict[str, Any]]]) -> bool:
"""
发送富文本消息
Args:
title: 消息标题
content_lines: 富文本内容行列表,每行是一个元素列表
例: [[{"tag": "text", "text": "内容"}]]
"""
config = await FeishuWebhook._get_config()
if not config["webhook_url"]:
logger.warning("飞书 Webhook 未配置,跳过发送")
return False
payload: Dict[str, Any] = {
"msg_type": "post",
"content": {
"post": {
"zh_cn": {
"title": title,
"content": content_lines,
}
}
},
}
return await FeishuWebhook._post(payload, config)
@staticmethod
async def send_interactive(title: str, content: str, button_text: str = "", button_url: str = "") -> bool:
"""
发送交互式卡片消息
Args:
title: 卡片标题
content: 卡片内容(Markdown 格式)
button_text: 按钮文字
button_url: 按钮链接
"""
config = await FeishuWebhook._get_config()
if not config["webhook_url"]:
logger.warning("飞书 Webhook 未配置,跳过发送")
return False
elements = [
{
"tag": "div",
"text": {
"content": content,
"tag": "lark_md",
},
}
]
if button_text and button_url:
elements.append({
"tag": "action",
"actions": [
{
"tag": "button",
"text": {"content": button_text, "tag": "plain_text"},
"url": button_url,
"type": "primary",
}
],
})
payload: Dict[str, Any] = {
"msg_type": "interactive",
"card": {
"header": {
"title": {"content": title, "tag": "plain_text"},
"template": "blue",
},
"elements": elements,
},
}
return await FeishuWebhook._post(payload, config)
@staticmethod
async def _post(payload: Dict[str, Any], config: dict = None) -> bool:
"""发送请求到飞书 Webhook"""
try:
if config is None:
config = await FeishuWebhook._get_config()
url = config["webhook_url"]
# 添加签名
webhook_secret = config.get("webhook_secret") or ""
if webhook_secret:
timestamp = str(int(time.time()))
sign = FeishuWebhook._sign(timestamp, webhook_secret)
payload["timestamp"] = timestamp
payload["sign"] = sign
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(url, json=payload)
resp.raise_for_status()
result = resp.json()
if result.get("code") == 0 or result.get("StatusCode") == 0:
logger.info("飞书 Webhook 消息发送成功")
return True
else:
logger.error(f"飞书 Webhook 发送失败: {result}")
return False
except Exception as e:
logger.error(f"飞书 Webhook 请求异常: {e}")
return False
class FeishuAppMessage:
"""飞书应用消息(企业自建应用)"""
TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
SEND_URL = "https://open.feishu.cn/open-apis/im/v1/messages"
BATCH_SEND_URL = "https://open.feishu.cn/open-apis/message/v4/batch_send/"
USER_ID_URL = "https://open.feishu.cn/open-apis/contact/v3/users/batch"
_tenant_access_token: Optional[str] = None
_token_expires_at: float = 0
@classmethod
def is_configured(cls) -> bool:
"""检查应用消息是否已配置(仅检查 env,同步兼容)"""
return bool(settings.FEISHU_APP_ID and settings.FEISHU_APP_SECRET)
@classmethod
async def is_configured_async(cls) -> bool:
"""检查应用消息是否已配置(通过 config_manager 三级获取)"""
from app.config_manager import config_manager
oauth_config = await config_manager.get_group("oauth_feishu")
return bool(oauth_config.get("app_id") and oauth_config.get("app_secret"))
@classmethod
async def _get_tenant_access_token(cls) -> Optional[str]:
"""获取 tenant_access_token(带缓存)"""
now = time.time()
if cls._tenant_access_token and now < cls._token_expires_at:
return cls._tenant_access_token
from app.config_manager import config_manager
oauth_config = await config_manager.get_group("oauth_feishu")
app_id = oauth_config.get("app_id")
app_secret = oauth_config.get("app_secret")
if not (app_id and app_secret):
logger.warning("飞书 app_id/app_secret 未配置")
return None
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
cls.TOKEN_URL,
json={
"app_id": app_id,
"app_secret": app_secret,
},
)
resp.raise_for_status()
result = resp.json()
if result.get("code") == 0:
cls._tenant_access_token = result["tenant_access_token"]
cls._token_expires_at = now + result.get("expire", 7200) - 300
logger.info("飞书 tenant_access_token 获取成功")
return cls._tenant_access_token
else:
logger.error(f"获取飞书 tenant_access_token 失败: {result}")
return None
except Exception as e:
logger.error(f"获取飞书 tenant_access_token 异常: {e}")
return None
@classmethod
async def send_text(cls, open_id: str, text: str) -> bool:
"""
发送文本消息给个人
Args:
open_id: 飞书用户 open_id
text: 消息内容
"""
content = {"text": text}
return await cls._send(open_id, "text", content)
@classmethod
async def send_interactive(cls, open_id: str, title: str, content_text: str) -> bool:
"""
发送交互式卡片消息给个人
Args:
open_id: 飞书用户 open_id
title: 卡片标题
content_text: 卡片内容(Markdown
"""
card = {
"header": {
"title": {"content": title, "tag": "plain_text"},
"template": "blue",
},
"elements": [
{
"tag": "div",
"text": {"content": content_text, "tag": "lark_md"},
}
],
}
return await cls._send(open_id, "interactive", card)
@classmethod
async def send_batch_text(cls, open_ids: List[str], text: str) -> bool:
"""
批量发送文本消息
Args:
open_ids: 飞书用户 open_id 列表
text: 消息内容
"""
if not await cls.is_configured_async():
logger.warning("飞书应用消息未配置,跳过发送")
return False
token = await cls._get_tenant_access_token()
if not token:
return False
try:
payload = {
"open_ids": open_ids,
"msg_type": "text",
"content": {"text": text},
}
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
cls.BATCH_SEND_URL,
headers={"Authorization": f"Bearer {token}"},
json=payload,
)
resp.raise_for_status()
result = resp.json()
if result.get("code") == 0:
logger.info(f"飞书批量消息发送成功: {len(open_ids)}")
return True
else:
logger.error(f"飞书批量消息发送失败: {result}")
return False
except Exception as e:
logger.error(f"飞书批量消息请求异常: {e}")
return False
@classmethod
async def _send(cls, open_id: str, msg_type: str, content: Dict[str, Any]) -> bool:
"""发送消息给个人"""
if not await cls.is_configured_async():
logger.warning("飞书应用消息未配置,跳过发送")
return False
token = await cls._get_tenant_access_token()
if not token:
return False
try:
import json
payload = {
"receive_id": open_id,
"msg_type": msg_type,
"content": json.dumps(content) if msg_type != "interactive" else json.dumps(content),
}
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
f"{cls.SEND_URL}?receive_id_type=open_id",
headers={"Authorization": f"Bearer {token}"},
json=payload,
)
resp.raise_for_status()
result = resp.json()
if result.get("code") == 0:
logger.info(f"飞书消息发送成功: {open_id}")
return True
else:
logger.error(f"飞书消息发送失败: {result}")
return False
except Exception as e:
logger.error(f"飞书消息请求异常: {e}")
return False
@classmethod
async def get_open_ids_by_union_ids(cls, union_ids: List[str]) -> Dict[str, str]:
"""
通过 union_id 批量获取 open_id
Args:
union_ids: 飞书 union_id 列表
Returns:
{union_id: open_id} 映射
"""
if not await cls.is_configured_async():
return {}
token = await cls._get_tenant_access_token()
if not token:
return {}
result_map = {}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
# 飞书批量查询接口每次最多50个
for i in range(0, len(union_ids), 50):
batch = union_ids[i:i + 50]
params = [("user_ids", uid) for uid in batch]
params.append(("user_id_type", "union_id"))
resp = await client.get(
cls.USER_ID_URL,
headers={"Authorization": f"Bearer {token}"},
params=params,
)
resp.raise_for_status()
data = resp.json()
if data.get("code") == 0:
items = data.get("data", {}).get("items", [])
for item in items:
union_id = item.get("union_id", "")
open_id = item.get("open_id", "")
if union_id and open_id:
result_map[union_id] = open_id
else:
logger.warning(f"飞书批量查询用户失败: {data}")
except Exception as e:
logger.error(f"飞书批量查询用户异常: {e}")
return result_map
def build_feishu_notification_text(title: str, content: str, app_name: str = None) -> str:
"""构建飞书通知的文本内容"""
app = app_name or settings.APP_NAME
return f"{app}{title}\n{content}"
@@ -0,0 +1,340 @@
"""
字段元数据自动生成工具
从 SQLAlchemy Model 或 Pydantic Schema 自动生成 FIELD_METADATA
"""
from typing import Dict, Any, Type, get_origin, get_args, Union
from sqlalchemy import inspect
from sqlalchemy.orm import DeclarativeMeta
from pydantic import BaseModel
import inspect as py_inspect
def generate_field_metadata(
model: Type[DeclarativeMeta],
sensitive_fields: list = None,
maskable_fields: list = None,
hidden_fields: list = None,
field_labels: Dict[str, str] = None
) -> Dict[str, Dict[str, Any]]:
"""
从 SQLAlchemy Model 自动生成字段元数据
Args:
model: SQLAlchemy Model 类
sensitive_fields: 敏感字段列表,如 ['mobile', 'email']
maskable_fields: 可脱敏字段列表,如 ['mobile', 'email', 'id_card']
hidden_fields: 默认隐藏字段列表,如 ['password']
field_labels: 字段中文名称映射,如 {'name': '姓名', 'mobile': '手机号'}
Returns:
字段元数据字典
Example:
>>> FIELD_METADATA = generate_field_metadata(
... User,
... sensitive_fields=['mobile', 'email', 'password'],
... maskable_fields=['mobile', 'email'],
... hidden_fields=['password'],
... field_labels={'name': '姓名', 'mobile': '手机号'}
... )
"""
sensitive_fields = sensitive_fields or []
maskable_fields = maskable_fields or []
hidden_fields = hidden_fields or []
field_labels = field_labels or {}
metadata = {}
# 获取 Model 的所有列
mapper = inspect(model)
for column in mapper.columns:
field_name = column.name
# 跳过内部字段
if field_name.startswith('_'):
continue
# 确定字段类型
field_type = _get_field_type(column.type)
# 确定默认权限
default_permission = "hidden" if field_name in hidden_fields else "read"
# 生成字段标签
label = field_labels.get(field_name) or _generate_label(field_name, column.comment)
metadata[field_name] = {
"label": label,
"field_type": field_type,
"sensitive": field_name in sensitive_fields,
"maskable": field_name in maskable_fields,
"default_permission": default_permission
}
return metadata
def _get_field_type(column_type) -> str:
"""
根据 SQLAlchemy 列类型确定字段类型
Args:
column_type: SQLAlchemy 列类型
Returns:
字段类型字符串: string/integer/boolean/datetime/float
"""
type_name = column_type.__class__.__name__.lower()
if 'int' in type_name or 'serial' in type_name:
return "integer"
elif 'bool' in type_name:
return "boolean"
elif 'date' in type_name or 'time' in type_name:
return "datetime"
elif 'float' in type_name or 'numeric' in type_name or 'decimal' in type_name:
return "float"
else:
return "string"
def _generate_label(field_name: str, comment: str = None) -> str:
"""
生成字段标签
优先使用数据库注释,如果没有则根据字段名生成
Args:
field_name: 字段名
comment: 数据库注释
Returns:
字段标签
"""
if comment:
return comment
# 常见字段名映射
common_labels = {
'id': 'ID',
'name': '名称',
'code': '编码',
'title': '标题',
'description': '描述',
'remark': '备注',
'status': '状态',
'sort': '排序',
'create_time': '创建时间',
'update_time': '更新时间',
'created_at': '创建时间',
'updated_at': '更新时间',
'is_deleted': '是否删除',
'is_active': '是否激活',
'username': '用户名',
'password': '密码',
'email': '邮箱',
'mobile': '手机号',
'phone': '电话',
'address': '地址',
'avatar': '头像',
'gender': '性别',
'age': '年龄',
'dept_id': '部门ID',
'user_id': '用户ID',
'role_id': '角色ID',
}
# 如果在常见映射中,直接返回
if field_name in common_labels:
return common_labels[field_name]
# 处理带前缀的字段
if field_name.startswith('sys_'):
base_name = field_name[4:]
if base_name in common_labels:
return f"系统{common_labels[base_name]}"
# 处理下划线分隔的字段名
if '_' in field_name:
parts = field_name.split('_')
# 尝试翻译每个部分
translated_parts = [common_labels.get(part, part.title()) for part in parts]
return ''.join(translated_parts)
# 默认返回首字母大写的字段名
return field_name.replace('_', ' ').title()
def generate_field_metadata_from_schema(
schema: Type[BaseModel],
model: Type[DeclarativeMeta] = None
) -> Dict[str, Dict[str, Any]]:
"""
从 Pydantic Response Schema 生成字段元数据
优势:
1. 根据 Schema 的 Optional 类型判断字段是否必填
2. 必填字段标记为 required=True,前端禁止隐藏
3. 可选字段可以被隐藏
Args:
schema: Pydantic Response Schema 类
model: SQLAlchemy Model 类(可选,用于获取数据库注释)
Returns:
字段元数据字典
"""
metadata = {}
# 获取 Schema 的所有字段
schema_fields = schema.model_fields
# 如果提供了 model,获取数据库注释
db_comments = {}
if model:
mapper = inspect(model)
for column in mapper.columns:
if column.comment:
db_comments[column.name] = column.comment
# 自动识别敏感/可脱敏字段的关键词
sensitive_keywords = ['password', 'passwd', 'pwd', 'secret', 'token', 'key']
maskable_keywords = ['mobile', 'phone', 'tel', 'email', 'mail', 'id_card', 'idcard', 'name']
hidden_keywords = ['password', 'passwd', 'pwd', 'secret', 'token', 'key']
for field_name, field_info in schema_fields.items():
# 判断字段是否必填(非 Optional)
is_required = field_info.is_required()
# 获取字段类型
field_type = _get_pydantic_field_type(field_info.annotation)
# 生成标签
label = db_comments.get(field_name) or _generate_label(field_name)
# 判断是否敏感/可脱敏
field_name_lower = field_name.lower()
is_sensitive = any(keyword in field_name_lower for keyword in sensitive_keywords)
is_maskable = any(keyword in field_name_lower for keyword in maskable_keywords)
is_hidden = any(keyword in field_name_lower for keyword in hidden_keywords)
# 默认权限
default_permission = "hidden" if is_hidden else "read"
metadata[field_name] = {
"label": label,
"field_type": field_type,
"required": is_required, # 必填字段,前端禁止隐藏
"sensitive": is_sensitive,
"maskable": is_maskable,
"default_permission": default_permission
}
return metadata
def _get_pydantic_field_type(annotation) -> str:
"""
从 Pydantic 字段类型获取字段类型字符串
Args:
annotation: Pydantic 字段类型注解
Returns:
字段类型字符串
"""
# 处理 Optional 类型
origin = get_origin(annotation)
if origin is Union:
args = get_args(annotation)
# Optional[X] 实际是 Union[X, None],取第一个非 None 类型
annotation = next((arg for arg in args if arg is not type(None)), str)
# 获取类型名称
if hasattr(annotation, '__name__'):
type_name = annotation.__name__.lower()
else:
type_name = str(annotation).lower()
if 'int' in type_name:
return "integer"
elif 'bool' in type_name:
return "boolean"
elif 'datetime' in type_name or 'date' in type_name:
return "datetime"
elif 'float' in type_name or 'decimal' in type_name:
return "float"
else:
return "string"
def auto_generate_field_metadata(model: Type[DeclarativeMeta]) -> Dict[str, Dict[str, Any]]:
"""
完全自动生成字段元数据(使用默认规则)
自动识别敏感字段:
- password, passwd, pwd: 密码相关
- mobile, phone, tel: 手机号相关
- email, mail: 邮箱相关
- id_card, idcard, identity: 身份证相关
- bank_card, bankcard: 银行卡相关
- secret, token, key: 密钥相关
Args:
model: SQLAlchemy Model 类
Returns:
字段元数据字典
"""
# 自动识别敏感字段
sensitive_keywords = [
'password', 'passwd', 'pwd',
'mobile', 'phone', 'tel',
'email', 'mail',
'id_card', 'idcard', 'identity',
'bank_card', 'bankcard',
'secret', 'token', 'key'
]
# 自动识别可脱敏字段
maskable_keywords = [
'mobile', 'phone', 'tel',
'email', 'mail',
'id_card', 'idcard', 'identity',
'name', # 姓名可脱敏
]
# 自动识别隐藏字段
hidden_keywords = [
'password', 'passwd', 'pwd',
'secret', 'token', 'key'
]
mapper = inspect(model)
sensitive_fields = []
maskable_fields = []
hidden_fields = []
for column in mapper.columns:
field_name = column.name.lower()
# 检查是否为敏感字段
if any(keyword in field_name for keyword in sensitive_keywords):
sensitive_fields.append(column.name)
# 检查是否可脱敏
if any(keyword in field_name for keyword in maskable_keywords):
maskable_fields.append(column.name)
# 检查是否默认隐藏
if any(keyword in field_name for keyword in hidden_keywords):
hidden_fields.append(column.name)
return generate_field_metadata(
model,
sensitive_fields=sensitive_fields,
maskable_fields=maskable_fields,
hidden_fields=hidden_fields
)
@@ -0,0 +1,225 @@
"""
字段权限缓存管理
使用 Redis 缓存字段权限配置,提高查询性能
"""
from typing import Dict, List, Optional
import json
from sqlalchemy.ext.asyncio import AsyncSession
from utils.redis import RedisClient
class FieldPermissionCache:
"""字段权限缓存管理器"""
# 缓存键前缀
CACHE_PREFIX = "field_perm"
# 缓存过期时间(秒)
CACHE_TTL = 3600 # 1小时
@classmethod
def _get_cache_key(cls, role_id: str, resource_type: str) -> str:
"""生成缓存键"""
return f"{cls.CACHE_PREFIX}:{role_id}:{resource_type}"
@classmethod
def _get_roles_cache_key(cls, role_ids: List[str], resource_type: str) -> str:
"""生成多角色缓存键"""
sorted_role_ids = sorted(role_ids)
roles_str = "_".join(sorted_role_ids)
return f"{cls.CACHE_PREFIX}:roles:{roles_str}:{resource_type}"
@classmethod
async def get(
cls,
role_id: str,
resource_type: str
) -> Optional[List[Dict]]:
"""
从缓存获取字段权限配置
:param role_id: 角色ID
:param resource_type: 资源类型
:return: 字段权限配置列表,如果不存在返回 None
"""
try:
redis_client = await RedisClient.get_client()
except Exception:
return None
cache_key = cls._get_cache_key(role_id, resource_type)
cached_data = await redis_client.get(cache_key)
if cached_data:
try:
return json.loads(cached_data)
except json.JSONDecodeError:
return None
return None
@classmethod
async def set(
cls,
role_id: str,
resource_type: str,
configs: List[Dict]
) -> None:
"""
设置字段权限配置到缓存
:param role_id: 角色ID
:param resource_type: 资源类型
:param configs: 字段权限配置列表
"""
try:
redis_client = await RedisClient.get_client()
except Exception:
return
cache_key = cls._get_cache_key(role_id, resource_type)
cache_data = json.dumps(configs, ensure_ascii=False)
await redis_client.setex(
cache_key,
cls.CACHE_TTL,
cache_data
)
@classmethod
async def get_merged(
cls,
role_ids: List[str],
resource_type: str
) -> Optional[Dict[str, Dict]]:
"""
从缓存获取合并后的字段权限配置
:param role_ids: 角色ID列表
:param resource_type: 资源类型
:return: 合并后的字段权限配置字典
"""
try:
redis_client = await RedisClient.get_client()
except Exception:
return None
cache_key = cls._get_roles_cache_key(role_ids, resource_type)
cached_data = await redis_client.get(cache_key)
if cached_data:
try:
return json.loads(cached_data)
except json.JSONDecodeError:
return None
return None
@classmethod
async def set_merged(
cls,
role_ids: List[str],
resource_type: str,
merged_config: Dict[str, Dict]
) -> None:
"""
设置合并后的字段权限配置到缓存
:param role_ids: 角色ID列表
:param resource_type: 资源类型
:param merged_config: 合并后的字段权限配置
"""
try:
redis_client = await RedisClient.get_client()
except Exception:
return
cache_key = cls._get_roles_cache_key(role_ids, resource_type)
cache_data = json.dumps(merged_config, ensure_ascii=False)
await redis_client.setex(
cache_key,
cls.CACHE_TTL,
cache_data
)
@classmethod
async def delete(
cls,
role_id: str,
resource_type: str
) -> None:
"""
删除指定角色和资源类型的缓存
:param role_id: 角色ID
:param resource_type: 资源类型
"""
try:
redis_client = await RedisClient.get_client()
except Exception:
return
cache_key = cls._get_cache_key(role_id, resource_type)
await redis_client.delete(cache_key)
@classmethod
async def delete_by_role(cls, role_id: str) -> None:
"""
删除指定角色的所有字段权限缓存
:param role_id: 角色ID
"""
try:
redis_client = await RedisClient.get_client()
except Exception:
return
# 删除该角色的所有缓存
pattern = f"{cls.CACHE_PREFIX}:{role_id}:*"
keys = await redis_client.keys(pattern)
if keys:
await redis_client.delete(*keys)
# 删除包含该角色的多角色缓存
pattern = f"{cls.CACHE_PREFIX}:roles:*"
keys = await redis_client.keys(pattern)
for key in keys:
if role_id in key:
await redis_client.delete(key)
@classmethod
async def delete_by_resource(cls, resource_type: str) -> None:
"""
删除指定资源类型的所有字段权限缓存
:param resource_type: 资源类型
"""
try:
redis_client = await RedisClient.get_client()
except Exception:
return
# 删除该资源类型的所有缓存
pattern = f"{cls.CACHE_PREFIX}:*:{resource_type}"
keys = await redis_client.keys(pattern)
if keys:
await redis_client.delete(*keys)
@classmethod
async def clear_all(cls) -> None:
"""清除所有字段权限缓存"""
try:
redis_client = await RedisClient.get_client()
except Exception:
return
pattern = f"{cls.CACHE_PREFIX}:*"
keys = await redis_client.keys(pattern)
if keys:
await redis_client.delete(*keys)
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
资源类型注册表 - 自动注册和管理所有资源类型
"""
import re
from typing import Dict, List, Type, Optional
class ResourceRegistry:
"""
资源类型注册表
自动注册所有定义了 RESOURCE_TYPE 的 Service
并提供查询、验证等功能
"""
# 存储所有已注册的资源类型
# 格式: {resource_type: {"service": ServiceClass, "model": ModelClass, "name": "显示名称"}}
_registry: Dict[str, Dict] = {}
@classmethod
def register(
cls,
resource_type: str,
service_class: Type,
display_name: Optional[str] = None,
application_id: Optional[str] = None,
field_metadata: Optional[Dict[str, Dict]] = None
):
"""
注册资源类型
:param resource_type: 资源类型标识(如 'customer', 'order'
:param service_class: Service 类
:param display_name: 显示名称(可选,如 '客户', '订单'
:param application_id: 应用ID(可选,用于子应用过滤)
:param field_metadata: 字段元数据(可选,用于字段权限配置)
"""
if resource_type in cls._registry:
# 如果已存在,更新信息
cls._registry[resource_type].update({
'service': service_class,
'display_name': display_name or cls._registry[resource_type].get('display_name', resource_type),
'application_id': application_id or cls._registry[resource_type].get('application_id'),
'field_metadata': field_metadata or cls._registry[resource_type].get('field_metadata')
})
else:
# 新注册
cls._registry[resource_type] = {
'service': service_class,
'model': service_class.model if service_class and hasattr(service_class, 'model') else None,
'display_name': display_name or cls._generate_display_name(resource_type),
'application_id': application_id,
'field_metadata': field_metadata
}
@classmethod
def _generate_display_name(cls, resource_type: str) -> str:
"""
根据资源类型生成显示名称
:param resource_type: 资源类型(如 'customer_order'
:return: 显示名称(如 '客户订单'
"""
# 将下划线分隔的单词转换为空格分隔,并首字母大写
words = resource_type.split('_')
return ' '.join(word.capitalize() for word in words)
@classmethod
def get_all_resource_types(cls) -> List[str]:
"""
获取所有已注册的资源类型
:return: 资源类型列表
"""
return list(cls._registry.keys())
@classmethod
def get_all_resources(cls, application_id: Optional[str] = None) -> List[Dict]:
"""
获取所有已注册的资源信息
:param application_id: 应用ID,如果指定则只返回该应用的资源(严格匹配)
:return: 资源信息列表,每个元素包含 resource_type, display_name, model_name 等
"""
resources = []
for resource_type, info in cls._registry.items():
# 如果指定了应用ID,只返回该应用的资源(严格匹配,不显示无应用ID的资源)
if application_id:
resource_app_id = info.get('application_id')
# 只显示匹配该应用ID的资源
if resource_app_id != application_id:
continue
resources.append({
'resource_type': resource_type,
'display_name': info.get('display_name', resource_type),
'model_name': info['model'].__name__ if info.get('model') else None,
'table_name': info['model'].__tablename__ if info.get('model') and hasattr(info['model'], '__tablename__') else None,
'application_id': info.get('application_id')
})
return resources
@classmethod
def get_resource(cls, resource_type: str) -> Optional[Dict]:
"""
根据资源类型获取完整的资源信息
:param resource_type: 资源类型
:return: 资源信息字典或 None
"""
return cls._registry.get(resource_type)
@classmethod
def get_service(cls, resource_type: str) -> Optional[Type]:
"""
根据资源类型获取对应的 Service 类
:param resource_type: 资源类型
:return: Service 类或 None
"""
info = cls._registry.get(resource_type)
return info.get('service') if info else None
@classmethod
def validate_resource_type(cls, resource_type: str) -> bool:
"""
验证资源类型是否已注册(别名方法)
:param resource_type: 资源类型
:return: True 表示已注册,False 表示未注册
"""
return resource_type in cls._registry
@classmethod
def validate(cls, resource_type: str) -> bool:
"""
验证资源类型是否已注册
:param resource_type: 资源类型
:return: True 表示已注册,False 表示未注册
"""
return resource_type in cls._registry
@classmethod
def unregister(cls, resource_type: str) -> bool:
"""
注销资源类型
:param resource_type: 资源类型标识
:return: True 表示成功注销,False 表示资源类型不存在
"""
if resource_type in cls._registry:
del cls._registry[resource_type]
return True
return False
@classmethod
def clear(cls):
"""清空注册表(主要用于测试)"""
cls._registry.clear()
@classmethod
def get_registry_info(cls) -> Dict:
"""
获取注册表的统计信息
:return: 统计信息字典
"""
return {
'total_count': len(cls._registry),
'resource_types': cls.get_all_resource_types(),
'resources': cls.get_all_resources()
}
def auto_generate_resource_type(model_class: Type) -> str:
"""
根据模型类自动生成资源类型
规则:
1. 优先使用表名(去除前缀如 core_, sys_
2. 如果没有表名,使用模型名(驼峰转下划线)
:param model_class: 模型类
:return: 资源类型字符串
"""
# 尝试从表名生成
if hasattr(model_class, '__tablename__'):
table_name = model_class.__tablename__
# 移除常见前缀
for prefix in ['core_', 'sys_', 'app_', 'biz_']:
if table_name.startswith(prefix):
return table_name[len(prefix):]
return table_name
# 从模型名生成(驼峰转下划线)
model_name = model_class.__name__
# Customer → customer, CustomerOrder → customer_order
resource_type = re.sub(r'(?<!^)(?=[A-Z])', '_', model_name).lower()
return resource_type
+348
View File
@@ -0,0 +1,348 @@
"""
短信发送工具
支持两种短信服务商:
1. 阿里云短信 (Alibaba Cloud SMS) - 使用 HTTP API + HMAC-SHA1 签名
2. 腾讯云短信 (Tencent Cloud SMS) - 使用 HTTP API + HMAC-SHA256 签名
配置通过 config_manager 三级获取(Redis → 数据库 → env)
"""
import base64
import hashlib
import hmac
import json
import logging
import time
import uuid
from datetime import datetime, timezone
from typing import Dict, List, Optional
from urllib.parse import quote_plus
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
class AliyunSMS:
"""阿里云短信服务"""
API_URL = "https://dysmsapi.aliyuncs.com"
@staticmethod
async def _get_config() -> dict:
"""通过 config_manager 三级获取阿里云短信配置"""
from app.config_manager import config_manager
group_config = await config_manager.get_group("notify_sms")
return {
"provider": group_config.get("provider") or "aliyun",
"access_key_id": group_config.get("aliyun_access_key_id") or None,
"access_key_secret": group_config.get("aliyun_access_key_secret") or None,
"sign_name": group_config.get("aliyun_sign_name") or None,
"template_code": group_config.get("aliyun_template_code") or None,
}
@staticmethod
async def is_configured_async() -> bool:
"""检查阿里云短信是否已配置"""
config = await AliyunSMS._get_config()
return bool(
config["access_key_id"]
and config["access_key_secret"]
and config["sign_name"]
and config["template_code"]
)
@staticmethod
def _percent_encode(s: str) -> str:
"""阿里云特殊 URL 编码"""
return quote_plus(s, safe="").replace("+", "%20").replace("*", "%2A").replace("%7E", "~")
@staticmethod
def _sign(params: dict, access_key_secret: str) -> str:
"""计算阿里云 API 签名"""
sorted_params = sorted(params.items())
canonicalized = "&".join(
f"{AliyunSMS._percent_encode(k)}={AliyunSMS._percent_encode(str(v))}"
for k, v in sorted_params
)
string_to_sign = f"GET&%2F&{AliyunSMS._percent_encode(canonicalized)}"
sign_key = f"{access_key_secret}&"
hmac_hash = hmac.new(
sign_key.encode("utf-8"),
string_to_sign.encode("utf-8"),
hashlib.sha1,
).digest()
return base64.b64encode(hmac_hash).decode("utf-8")
@staticmethod
async def send(
phone_numbers: List[str],
template_param: Optional[Dict[str, str]] = None,
) -> Dict[str, bool]:
"""
发送短信
Args:
phone_numbers: 手机号列表
template_param: 模板参数,如 {"title": "审批通知", "content": "您有一条待审批任务"}
Returns:
{phone: success} 字典
"""
config = await AliyunSMS._get_config()
if not config["access_key_id"]:
logger.warning("阿里云短信未配置,跳过发送")
return {phone: False for phone in phone_numbers}
results = {}
for phone in phone_numbers:
try:
params = {
"AccessKeyId": config["access_key_id"],
"Action": "SendSms",
"Format": "JSON",
"PhoneNumbers": phone,
"RegionId": "cn-hangzhou",
"SignName": config["sign_name"],
"SignatureMethod": "HMAC-SHA1",
"SignatureNonce": str(uuid.uuid4()),
"SignatureVersion": "1.0",
"TemplateCode": config["template_code"],
"Timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"Version": "2017-05-25",
}
if template_param:
params["TemplateParam"] = json.dumps(template_param, ensure_ascii=False)
signature = AliyunSMS._sign(params, config["access_key_secret"])
params["Signature"] = signature
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(AliyunSMS.API_URL, params=params)
resp.raise_for_status()
data = resp.json()
if data.get("Code") == "OK":
results[phone] = True
logger.info(f"阿里云短信发送成功: {phone}")
else:
results[phone] = False
logger.warning(f"阿里云短信发送失败: {phone}, Code={data.get('Code')}, Message={data.get('Message')}")
except Exception as e:
results[phone] = False
logger.error(f"阿里云短信发送异常: {phone}, {e}")
return results
class TencentSMS:
"""腾讯云短信服务"""
API_URL = "https://sms.tencentcloudapi.com"
SERVICE = "sms"
VERSION = "2021-01-11"
ACTION = "SendSms"
@staticmethod
async def _get_config() -> dict:
"""通过 config_manager 三级获取腾讯云短信配置"""
from app.config_manager import config_manager
group_config = await config_manager.get_group("notify_sms")
return {
"provider": group_config.get("provider") or "aliyun",
"secret_id": group_config.get("tencent_secret_id") or None,
"secret_key": group_config.get("tencent_secret_key") or None,
"sdk_app_id": group_config.get("tencent_sdk_app_id") or None,
"sign_name": group_config.get("tencent_sign_name") or None,
"template_id": group_config.get("tencent_template_id") or None,
}
@staticmethod
async def is_configured_async() -> bool:
"""检查腾讯云短信是否已配置"""
config = await TencentSMS._get_config()
return bool(
config["secret_id"]
and config["secret_key"]
and config["sdk_app_id"]
and config["sign_name"]
and config["template_id"]
)
@staticmethod
def _sign_v3(secret_key: str, date: str, service: str, string_to_sign: str) -> str:
"""腾讯云 TC3-HMAC-SHA256 签名"""
def _hmac_sha256(key: bytes, msg: str) -> bytes:
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
secret_date = _hmac_sha256(f"TC3{secret_key}".encode("utf-8"), date)
secret_service = _hmac_sha256(secret_date, service)
secret_signing = _hmac_sha256(secret_service, "tc3_request")
return hmac.new(secret_signing, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
@staticmethod
async def send(
phone_numbers: List[str],
template_param_set: Optional[List[str]] = None,
) -> Dict[str, bool]:
"""
发送短信
Args:
phone_numbers: 手机号列表(需带国际区号前缀,如 +86)
template_param_set: 模板参数列表,如 ["审批通知", "您有一条待审批任务"]
Returns:
{phone: success} 字典
"""
config = await TencentSMS._get_config()
if not config["secret_id"]:
logger.warning("腾讯云短信未配置,跳过发送")
return {phone: False for phone in phone_numbers}
# 腾讯云手机号需要 +86 前缀
formatted_phones = []
for phone in phone_numbers:
if not phone.startswith("+"):
phone = f"+86{phone}"
formatted_phones.append(phone)
payload = {
"SmsSdkAppId": config["sdk_app_id"],
"SignName": config["sign_name"],
"TemplateId": config["template_id"],
"PhoneNumberSet": formatted_phones,
}
if template_param_set:
payload["TemplateParamSet"] = template_param_set
payload_json = json.dumps(payload)
# 构建签名
now = int(time.time())
date = datetime.fromtimestamp(now, tz=timezone.utc).strftime("%Y-%m-%d")
# CanonicalRequest
hashed_payload = hashlib.sha256(payload_json.encode("utf-8")).hexdigest()
canonical_request = (
f"POST\n/\n\n"
f"content-type:application/json; charset=utf-8\n"
f"host:sms.tencentcloudapi.com\n\n"
f"content-type;host\n"
f"{hashed_payload}"
)
# StringToSign
credential_scope = f"{date}/{TencentSMS.SERVICE}/tc3_request"
hashed_canonical = hashlib.sha256(canonical_request.encode("utf-8")).hexdigest()
string_to_sign = f"TC3-HMAC-SHA256\n{now}\n{credential_scope}\n{hashed_canonical}"
# Signature
signature = TencentSMS._sign_v3(config["secret_key"], date, TencentSMS.SERVICE, string_to_sign)
# Authorization
authorization = (
f"TC3-HMAC-SHA256 "
f"Credential={config['secret_id']}/{credential_scope}, "
f"SignedHeaders=content-type;host, "
f"Signature={signature}"
)
headers = {
"Authorization": authorization,
"Content-Type": "application/json; charset=utf-8",
"Host": "sms.tencentcloudapi.com",
"X-TC-Action": TencentSMS.ACTION,
"X-TC-Version": TencentSMS.VERSION,
"X-TC-Timestamp": str(now),
}
results = {phone: False for phone in phone_numbers}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(TencentSMS.API_URL, headers=headers, content=payload_json)
resp.raise_for_status()
data = resp.json()
response = data.get("Response", {})
if "Error" in response:
logger.error(
f"腾讯云短信发送失败: Code={response['Error'].get('Code')}, "
f"Message={response['Error'].get('Message')}"
)
return results
send_status_set = response.get("SendStatusSet", [])
for i, status in enumerate(send_status_set):
original_phone = phone_numbers[i] if i < len(phone_numbers) else "unknown"
if status.get("Code") == "Ok":
results[original_phone] = True
logger.info(f"腾讯云短信发送成功: {original_phone}")
else:
logger.warning(
f"腾讯云短信发送失败: {original_phone}, "
f"Code={status.get('Code')}, Message={status.get('Message')}"
)
except Exception as e:
logger.error(f"腾讯云短信发送异常: {e}")
return results
class SMSSender:
"""
统一短信发送器
根据配置的 provider 自动选择阿里云或腾讯云
"""
@staticmethod
async def _get_provider() -> str:
"""获取当前配置的短信服务商"""
from app.config_manager import config_manager
group_config = await config_manager.get_group("notify_sms")
return group_config.get("provider") or "aliyun"
@staticmethod
async def is_configured_async() -> bool:
"""检查短信服务是否已配置"""
provider = await SMSSender._get_provider()
if provider == "tencent":
return await TencentSMS.is_configured_async()
return await AliyunSMS.is_configured_async()
@staticmethod
async def send(
phone_numbers: List[str],
title: str,
content: str,
) -> Dict[str, bool]:
"""
发送短信通知
Args:
phone_numbers: 手机号列表
title: 通知标题
content: 通知内容
Returns:
{phone: success} 字典
"""
if not phone_numbers:
return {}
provider = await SMSSender._get_provider()
if provider == "tencent":
return await TencentSMS.send(
phone_numbers=phone_numbers,
template_param_set=[title, content],
)
else:
return await AliyunSMS.send(
phone_numbers=phone_numbers,
template_param={"title": title, "content": content},
)
+29
View File
@@ -0,0 +1,29 @@
"""
时区工具模块
提供全局统一的时区配置和日期格式化工具
"""
from datetime import datetime, timezone, timedelta
from zoneinfo import ZoneInfo
from app.config import settings
# 根据配置加载时区
APP_TIMEZONE = ZoneInfo(settings.TIMEZONE)
def format_datetime(dt: datetime, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
"""将 datetime 格式化为指定格式的字符串,自动处理时区转换"""
if dt is None:
return ""
if dt.tzinfo is not None:
dt = dt.astimezone(APP_TIMEZONE)
return dt.strftime(fmt)
def convert_to_app_timezone(dt: datetime) -> datetime:
"""将 datetime 转换为应用配置的时区"""
if dt is None:
return dt
if dt.tzinfo is not None:
return dt.astimezone(APP_TIMEZONE)
return dt
+236
View File
@@ -0,0 +1,236 @@
"""
微信公众号模板消息工具
通过微信服务号向关注用户发送模板消息。
前提条件:
1. 需要微信认证服务号(订阅号无模板消息权限)
2. 用户需关注该公众号,系统记录用户的 openid
3. 在公众号后台添加消息模板,获取 template_id
4. 复用 WECHAT_APP_ID / WECHAT_APP_SECRET(与 OAuth 登录同一应用)
"""
import logging
import time
from typing import Any, Dict, List, Optional
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
class WechatMPMessage:
"""微信公众号模板消息"""
TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token"
SEND_URL = "https://api.weixin.qq.com/cgi-bin/message/template/send"
_access_token: Optional[str] = None
_token_expires_at: float = 0
@classmethod
def is_configured(cls) -> bool:
"""检查是否已配置(仅检查 env,同步兼容)"""
return bool(
settings.WECHAT_APP_ID
and settings.WECHAT_APP_SECRET
and settings.WECHAT_MP_TEMPLATE_ID
)
@classmethod
async def is_configured_async(cls) -> bool:
"""检查是否已配置(通过 config_manager 三级获取)"""
from app.config_manager import config_manager
oauth_config = await config_manager.get_group("oauth_wechat")
mp_config = await config_manager.get_group("notify_wechat_mp")
return bool(
oauth_config.get("app_id")
and oauth_config.get("app_secret")
and mp_config.get("template_id")
)
@classmethod
async def _get_access_token(cls) -> Optional[str]:
"""获取 access_token(带缓存)"""
now = time.time()
if cls._access_token and now < cls._token_expires_at:
return cls._access_token
from app.config_manager import config_manager
oauth_config = await config_manager.get_group("oauth_wechat")
app_id = oauth_config.get("app_id")
app_secret = oauth_config.get("app_secret")
if not (app_id and app_secret):
logger.warning("微信 app_id/app_secret 未配置")
return None
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
cls.TOKEN_URL,
params={
"grant_type": "client_credential",
"appid": app_id,
"secret": app_secret,
},
)
resp.raise_for_status()
result = resp.json()
if "access_token" in result:
cls._access_token = result["access_token"]
cls._token_expires_at = now + result.get("expires_in", 7200) - 300
logger.info("微信公众号 access_token 获取成功")
return cls._access_token
else:
logger.error(f"获取微信公众号 access_token 失败: {result}")
return None
except Exception as e:
logger.error(f"获取微信公众号 access_token 异常: {e}")
return None
@classmethod
async def send_template(
cls,
openid: str,
template_data: Dict[str, Dict[str, str]],
template_id: str = None,
url: str = None,
miniprogram: Dict[str, str] = None,
) -> bool:
"""
发送模板消息
Args:
openid: 用户的 openid
template_data: 模板数据,格式如:
{
"first": {"value": "通知标题", "color": "#173177"},
"keyword1": {"value": "内容1"},
"keyword2": {"value": "内容2"},
"remark": {"value": "备注信息"}
}
template_id: 模板ID,默认使用配置的 WECHAT_MP_TEMPLATE_ID
url: 点击跳转链接,默认使用配置的 WECHAT_MP_URL
miniprogram: 跳转小程序配置 {"appid": "...", "pagepath": "..."}
"""
if not await cls.is_configured_async():
logger.warning("微信公众号模板消息未配置,跳过发送")
return False
token = await cls._get_access_token()
if not token:
return False
from app.config_manager import config_manager
mp_config = await config_manager.get_group("notify_wechat_mp")
try:
payload: Dict[str, Any] = {
"touser": openid,
"template_id": template_id or mp_config.get("template_id") or settings.WECHAT_MP_TEMPLATE_ID,
"data": template_data,
}
# 跳转链接
jump_url = url or mp_config.get("url") or settings.WECHAT_MP_URL
if jump_url:
payload["url"] = jump_url
# 小程序跳转(优先级高于 url
mini = miniprogram
mini_appid = mp_config.get("mini_appid") or settings.WECHAT_MP_MINI_APPID
if not mini and mini_appid:
mini = {
"appid": mini_appid,
"pagepath": mp_config.get("mini_page") or settings.WECHAT_MP_MINI_PAGE or "",
}
if mini:
payload["miniprogram"] = mini
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
f"{cls.SEND_URL}?access_token={token}",
json=payload,
)
resp.raise_for_status()
result = resp.json()
if result.get("errcode") == 0:
logger.info(f"微信模板消息发送成功: {openid}")
return True
else:
logger.error(f"微信模板消息发送失败: {result}")
return False
except Exception as e:
logger.error(f"微信模板消息请求异常: {e}")
return False
@classmethod
async def send_notification(
cls,
openid: str,
title: str,
content: str,
remark: str = "",
url: str = None,
) -> bool:
"""
发送通知类模板消息(简化接口)
使用通用模板格式:
first: 标题
keyword1: 通知内容
keyword2: 时间
remark: 备注
Args:
openid: 用户的 openid
title: 通知标题
content: 通知内容
remark: 备注信息
url: 点击跳转链接
"""
import datetime
now_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
app_name = settings.APP_NAME
template_data = {
"first": {"value": title, "color": "#173177"},
"keyword1": {"value": content},
"keyword2": {"value": now_str},
"remark": {"value": remark or f"来自 {app_name}"},
}
return await cls.send_template(openid, template_data, url=url)
@classmethod
async def batch_send_notification(
cls,
openids: List[str],
title: str,
content: str,
remark: str = "",
url: str = None,
) -> int:
"""
批量发送通知类模板消息
Args:
openids: 用户 openid 列表
title: 通知标题
content: 通知内容
remark: 备注信息
url: 点击跳转链接
Returns:
成功发送的数量
"""
success_count = 0
for openid in openids:
try:
ok = await cls.send_notification(openid, title, content, remark, url)
if ok:
success_count += 1
except Exception as e:
logger.warning(f"微信模板消息发送失败 [{openid}]: {e}")
return success_count
+324
View File
@@ -0,0 +1,324 @@
"""
企业微信通知工具
支持两种通知方式:
1. Webhook 群机器人 - 向企业微信群聊发送消息
2. 应用消息 - 通过企业自建应用向个人发送消息(复用 OAuth 配置)
"""
import logging
import time
from typing import Any, Dict, List, Optional
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
class WecomWebhook:
"""企业微信群机器人 Webhook"""
@staticmethod
async def _get_config() -> dict:
"""通过 config_manager 三级获取企业微信 Webhook 配置"""
from app.config_manager import config_manager
group_config = await config_manager.get_group("notify_wecom")
return {
"webhook_url": group_config.get("webhook_url") or None,
}
@staticmethod
def is_configured() -> bool:
"""检查 Webhook 是否已配置(仅检查 env,同步兼容)"""
return bool(settings.WECOM_WEBHOOK_URL)
@staticmethod
async def is_configured_async() -> bool:
"""检查 Webhook 是否已配置(通过 config_manager 三级获取)"""
config = await WecomWebhook._get_config()
return bool(config["webhook_url"])
@staticmethod
async def send_text(content: str, mentioned_list: List[str] = None, mentioned_mobile_list: List[str] = None) -> bool:
"""
发送文本消息
Args:
content: 消息内容(最长2048字节)
mentioned_list: @指定用户的 userid 列表,@all 表示所有人
mentioned_mobile_list: @指定手机号列表
"""
config = await WecomWebhook._get_config()
if not config["webhook_url"]:
logger.warning("企业微信 Webhook 未配置,跳过发送")
return False
payload: Dict[str, Any] = {
"msgtype": "text",
"text": {
"content": content,
"mentioned_list": mentioned_list or [],
"mentioned_mobile_list": mentioned_mobile_list or [],
},
}
return await WecomWebhook._post(payload, config)
@staticmethod
async def send_markdown(content: str) -> bool:
"""
发送 Markdown 消息
Args:
content: Markdown 格式内容(最长4096字节)
支持语法: 标题(#)、加粗(**)、链接、引用(>)、字体颜色(<font>)
"""
config = await WecomWebhook._get_config()
if not config["webhook_url"]:
logger.warning("企业微信 Webhook 未配置,跳过发送")
return False
payload: Dict[str, Any] = {
"msgtype": "markdown",
"markdown": {"content": content},
}
return await WecomWebhook._post(payload, config)
@staticmethod
async def send_news(title: str, description: str = "", url: str = "", picurl: str = "") -> bool:
"""
发送图文消息
Args:
title: 标题(不超过128字节)
description: 描述(不超过512字节)
url: 点击跳转链接
picurl: 图片链接
"""
config = await WecomWebhook._get_config()
if not config["webhook_url"]:
logger.warning("企业微信 Webhook 未配置,跳过发送")
return False
article: Dict[str, Any] = {"title": title}
if description:
article["description"] = description
if url:
article["url"] = url
if picurl:
article["picurl"] = picurl
payload: Dict[str, Any] = {
"msgtype": "news",
"news": {"articles": [article]},
}
return await WecomWebhook._post(payload, config)
@staticmethod
async def _post(payload: Dict[str, Any], config: dict = None) -> bool:
"""发送请求到企业微信 Webhook"""
try:
if config is None:
config = await WecomWebhook._get_config()
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(config["webhook_url"], json=payload)
resp.raise_for_status()
result = resp.json()
if result.get("errcode") == 0:
logger.info("企业微信 Webhook 消息发送成功")
return True
else:
logger.error(f"企业微信 Webhook 发送失败: {result}")
return False
except Exception as e:
logger.error(f"企业微信 Webhook 请求异常: {e}")
return False
class WecomAppMessage:
"""企业微信应用消息(企业自建应用,复用 OAuth 配置)"""
TOKEN_URL = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
SEND_URL = "https://qyapi.weixin.qq.com/cgi-bin/message/send"
_access_token: Optional[str] = None
_token_expires_at: float = 0
@classmethod
def is_configured(cls) -> bool:
"""检查应用消息是否已配置(仅检查 env,同步兼容)"""
return bool(
settings.WECOM_CORP_ID
and settings.WECOM_APP_SECRET
and settings.WECOM_AGENT_ID
)
@classmethod
async def is_configured_async(cls) -> bool:
"""检查应用消息是否已配置(通过 config_manager 三级获取)"""
from app.config_manager import config_manager
oauth_config = await config_manager.get_group("oauth_wecom")
return bool(
oauth_config.get("corp_id")
and oauth_config.get("app_secret")
and oauth_config.get("agent_id")
)
@classmethod
async def _get_access_token(cls) -> Optional[str]:
"""获取 access_token(带缓存)"""
now = time.time()
if cls._access_token and now < cls._token_expires_at:
return cls._access_token
from app.config_manager import config_manager
oauth_config = await config_manager.get_group("oauth_wecom")
corp_id = oauth_config.get("corp_id")
app_secret = oauth_config.get("app_secret")
if not (corp_id and app_secret):
logger.warning("企业微信 corp_id/app_secret 未配置")
return None
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
cls.TOKEN_URL,
params={
"corpid": corp_id,
"corpsecret": app_secret,
},
)
resp.raise_for_status()
result = resp.json()
if result.get("errcode") == 0:
cls._access_token = result["access_token"]
cls._token_expires_at = now + result.get("expires_in", 7200) - 300
logger.info("企业微信 access_token 获取成功")
return cls._access_token
else:
logger.error(f"获取企业微信 access_token 失败: {result}")
return None
except Exception as e:
logger.error(f"获取企业微信 access_token 异常: {e}")
return None
@classmethod
async def send_text(cls, userid_list: List[str], content: str) -> bool:
"""
发送文本消息给个人
Args:
userid_list: 企业微信 userid 列表(最多1000个,用|分隔)
content: 消息内容
"""
from app.config_manager import config_manager
oauth_config = await config_manager.get_group("oauth_wecom")
agent_id = oauth_config.get("agent_id") or settings.WECOM_AGENT_ID
msg = {
"touser": "|".join(userid_list[:1000]),
"msgtype": "text",
"agentid": int(agent_id),
"text": {"content": content},
}
return await cls._send(msg)
@classmethod
async def send_textcard(
cls,
userid_list: List[str],
title: str,
description: str,
url: str = "",
btntxt: str = "详情",
) -> bool:
"""
发送文本卡片消息给个人
Args:
userid_list: 企业微信 userid 列表
title: 标题(不超过128字节)
description: 描述(支持<div>标签)
url: 点击跳转链接
btntxt: 按钮文字
"""
from app.config_manager import config_manager
oauth_config = await config_manager.get_group("oauth_wecom")
agent_id = oauth_config.get("agent_id") or settings.WECOM_AGENT_ID
msg = {
"touser": "|".join(userid_list[:1000]),
"msgtype": "textcard",
"agentid": int(agent_id),
"textcard": {
"title": title,
"description": description,
"url": url or "URL",
"btntxt": btntxt,
},
}
return await cls._send(msg)
@classmethod
async def send_markdown(cls, userid_list: List[str], content: str) -> bool:
"""
发送 Markdown 消息给个人
Args:
userid_list: 企业微信 userid 列表
content: Markdown 格式内容
"""
from app.config_manager import config_manager
oauth_config = await config_manager.get_group("oauth_wecom")
agent_id = oauth_config.get("agent_id") or settings.WECOM_AGENT_ID
msg = {
"touser": "|".join(userid_list[:1000]),
"msgtype": "markdown",
"agentid": int(agent_id),
"markdown": {"content": content},
}
return await cls._send(msg)
@classmethod
async def _send(cls, msg: Dict[str, Any]) -> bool:
"""发送应用消息"""
if not await cls.is_configured_async():
logger.warning("企业微信应用消息未配置,跳过发送")
return False
token = await cls._get_access_token()
if not token:
return False
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
cls.SEND_URL,
params={"access_token": token},
json=msg,
)
resp.raise_for_status()
result = resp.json()
if result.get("errcode") == 0:
logger.info(f"企业微信应用消息发送成功")
return True
else:
logger.error(f"企业微信应用消息发送失败: {result}")
return False
except Exception as e:
logger.error(f"企业微信应用消息请求异常: {e}")
return False
def build_wecom_markdown(title: str, content: str, app_name: str = None) -> str:
"""
构建企业微信通知的 Markdown 内容
Args:
title: 通知标题
content: 通知内容
app_name: 应用名称
"""
app = app_name or settings.APP_NAME
return f"**{title}**\n{content}\n> 来自 {app}"