feat: 新增告警、成本、配额、微信模块及缓存服务
All checks were successful
continuous-integration/drone/push Build is passing

- 新增告警模块 (alerts): 告警规则配置与触发
- 新增成本管理模块 (cost): 成本统计与分析
- 新增配额模块 (quota): 配额管理与限制
- 新增微信模块 (wechat): 微信相关功能接口
- 新增缓存服务 (cache): Redis 缓存封装
- 新增请求日志中间件 (request_logger)
- 新增异常处理和链路追踪中间件
- 更新 dashboard 前端展示
- 更新 SDK stats_client 功能
This commit is contained in:
111
2026-01-24 16:53:47 +08:00
parent eab2533c36
commit 6c6c48cf71
29 changed files with 4607 additions and 41 deletions

View File

@@ -0,0 +1,371 @@
"""企业微信服务"""
import hashlib
import time
import logging
from typing import Optional, Dict, Any
from dataclasses import dataclass
import httpx
from ..config import get_settings
from .cache import get_cache
from .crypto import decrypt_config
logger = logging.getLogger(__name__)
settings = get_settings()
@dataclass
class WechatConfig:
"""企业微信应用配置"""
corp_id: str
agent_id: str
secret: str
class WechatService:
"""企业微信服务
提供access_token获取、JS-SDK签名、OAuth2等功能
使用示例:
wechat = WechatService(corp_id="wwxxxx", agent_id="1000001", secret="xxx")
# 获取access_token
token = await wechat.get_access_token()
# 获取JS-SDK签名
signature = await wechat.get_jssdk_signature("https://example.com/page")
"""
# 企业微信API基础URL
BASE_URL = "https://qyapi.weixin.qq.com"
def __init__(self, corp_id: str, agent_id: str, secret: str):
"""初始化企业微信服务
Args:
corp_id: 企业ID
agent_id: 应用AgentId
secret: 应用Secret明文
"""
self.corp_id = corp_id
self.agent_id = agent_id
self.secret = secret
self._cache = get_cache()
@classmethod
def from_wechat_app(cls, wechat_app) -> "WechatService":
"""从TenantWechatApp模型创建服务实例
Args:
wechat_app: TenantWechatApp数据库模型
Returns:
WechatService实例
"""
secret = ""
if wechat_app.secret_encrypted:
try:
secret = decrypt_config(wechat_app.secret_encrypted)
except Exception as e:
logger.error(f"Failed to decrypt secret: {e}")
return cls(
corp_id=wechat_app.corp_id,
agent_id=wechat_app.agent_id,
secret=secret
)
def _cache_key(self, key_type: str) -> str:
"""生成缓存键"""
return f"wechat:{self.corp_id}:{self.agent_id}:{key_type}"
async def get_access_token(self, force_refresh: bool = False) -> Optional[str]:
"""获取access_token
企业微信access_token有效期7200秒需要缓存
Args:
force_refresh: 是否强制刷新
Returns:
access_token或None
"""
cache_key = self._cache_key("access_token")
# 尝试从缓存获取
if not force_refresh:
cached = self._cache.get(cache_key)
if cached:
logger.debug(f"Access token from cache: {cached[:20]}...")
return cached
# 从企业微信API获取
url = f"{self.BASE_URL}/cgi-bin/gettoken"
params = {
"corpid": self.corp_id,
"corpsecret": self.secret
}
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(url, params=params)
result = response.json()
if result.get("errcode", 0) != 0:
logger.error(f"Get access_token failed: {result}")
return None
access_token = result.get("access_token")
expires_in = result.get("expires_in", 7200)
# 缓存提前200秒过期以确保安全
self._cache.set(
cache_key,
access_token,
ttl=min(expires_in - 200, settings.WECHAT_ACCESS_TOKEN_EXPIRE)
)
logger.info(f"Got new access_token for {self.corp_id}")
return access_token
except Exception as e:
logger.error(f"Get access_token error: {e}")
return None
async def get_jsapi_ticket(self, force_refresh: bool = False) -> Optional[str]:
"""获取jsapi_ticket
用于生成JS-SDK签名
Args:
force_refresh: 是否强制刷新
Returns:
jsapi_ticket或None
"""
cache_key = self._cache_key("jsapi_ticket")
# 尝试从缓存获取
if not force_refresh:
cached = self._cache.get(cache_key)
if cached:
logger.debug(f"JSAPI ticket from cache: {cached[:20]}...")
return cached
# 先获取access_token
access_token = await self.get_access_token()
if not access_token:
return None
# 获取jsapi_ticket
url = f"{self.BASE_URL}/cgi-bin/get_jsapi_ticket"
params = {"access_token": access_token}
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(url, params=params)
result = response.json()
if result.get("errcode", 0) != 0:
logger.error(f"Get jsapi_ticket failed: {result}")
return None
ticket = result.get("ticket")
expires_in = result.get("expires_in", 7200)
# 缓存
self._cache.set(
cache_key,
ticket,
ttl=min(expires_in - 200, settings.WECHAT_JSAPI_TICKET_EXPIRE)
)
logger.info(f"Got new jsapi_ticket for {self.corp_id}")
return ticket
except Exception as e:
logger.error(f"Get jsapi_ticket error: {e}")
return None
async def get_jssdk_signature(
self,
url: str,
noncestr: Optional[str] = None,
timestamp: Optional[int] = None
) -> Optional[Dict[str, Any]]:
"""生成JS-SDK签名
Args:
url: 当前页面URL不含#及其后面部分)
noncestr: 随机字符串,可选
timestamp: 时间戳,可选
Returns:
签名信息字典包含signature, noncestr, timestamp, appId等
"""
ticket = await self.get_jsapi_ticket()
if not ticket:
return None
# 生成随机字符串和时间戳
if noncestr is None:
import secrets
noncestr = secrets.token_hex(8)
if timestamp is None:
timestamp = int(time.time())
# 构建签名字符串
sign_str = f"jsapi_ticket={ticket}&noncestr={noncestr}&timestamp={timestamp}&url={url}"
# SHA1签名
signature = hashlib.sha1(sign_str.encode()).hexdigest()
return {
"appId": self.corp_id,
"agentId": self.agent_id,
"timestamp": timestamp,
"nonceStr": noncestr,
"signature": signature,
"url": url
}
def get_oauth2_url(
self,
redirect_uri: str,
scope: str = "snsapi_base",
state: str = ""
) -> str:
"""生成OAuth2授权URL
Args:
redirect_uri: 授权后重定向的URL
scope: 应用授权作用域
- snsapi_base: 静默授权,只能获取成员基础信息
- snsapi_privateinfo: 手动授权,可获取成员详细信息
state: 重定向后会带上state参数
Returns:
OAuth2授权URL
"""
import urllib.parse
encoded_uri = urllib.parse.quote(redirect_uri, safe='')
url = (
f"https://open.weixin.qq.com/connect/oauth2/authorize"
f"?appid={self.corp_id}"
f"&redirect_uri={encoded_uri}"
f"&response_type=code"
f"&scope={scope}"
f"&state={state}"
f"&agentid={self.agent_id}"
f"#wechat_redirect"
)
return url
async def get_user_info_by_code(self, code: str) -> Optional[Dict[str, Any]]:
"""通过OAuth2 code获取用户信息
Args:
code: OAuth2回调返回的code
Returns:
用户信息字典包含UserId, DeviceId等
"""
access_token = await self.get_access_token()
if not access_token:
return None
url = f"{self.BASE_URL}/cgi-bin/auth/getuserinfo"
params = {
"access_token": access_token,
"code": code
}
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(url, params=params)
result = response.json()
if result.get("errcode", 0) != 0:
logger.error(f"Get user info by code failed: {result}")
return None
return {
"user_id": result.get("userid") or result.get("UserId"),
"device_id": result.get("deviceid") or result.get("DeviceId"),
"open_id": result.get("openid") or result.get("OpenId"),
"external_userid": result.get("external_userid"),
}
except Exception as e:
logger.error(f"Get user info by code error: {e}")
return None
async def get_user_detail(self, user_id: str) -> Optional[Dict[str, Any]]:
"""获取成员详细信息
Args:
user_id: 成员UserID
Returns:
成员详细信息
"""
access_token = await self.get_access_token()
if not access_token:
return None
url = f"{self.BASE_URL}/cgi-bin/user/get"
params = {
"access_token": access_token,
"userid": user_id
}
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(url, params=params)
result = response.json()
if result.get("errcode", 0) != 0:
logger.error(f"Get user detail failed: {result}")
return None
return {
"userid": result.get("userid"),
"name": result.get("name"),
"department": result.get("department"),
"position": result.get("position"),
"mobile": result.get("mobile"),
"email": result.get("email"),
"avatar": result.get("avatar"),
"status": result.get("status"),
}
except Exception as e:
logger.error(f"Get user detail error: {e}")
return None
async def get_wechat_service_by_id(
wechat_app_id: int,
db_session
) -> Optional[WechatService]:
"""根据企微应用ID获取服务实例
Args:
wechat_app_id: platform_tenant_wechat_apps表的ID
db_session: 数据库session
Returns:
WechatService实例或None
"""
from ..models.tenant_wechat_app import TenantWechatApp
wechat_app = db_session.query(TenantWechatApp).filter(
TenantWechatApp.id == wechat_app_id,
TenantWechatApp.status == 1
).first()
if not wechat_app:
return None
return WechatService.from_wechat_app(wechat_app)