Build lightweight AI agent admin
This commit is contained in:
@@ -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)")
|
||||
@@ -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
|
||||
@@ -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 "***"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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}"
|
||||
@@ -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>"""
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
Reference in New Issue
Block a user