feat: 2026-07-11 全量更新 - 代办集成+会议室预定+知识迭代修复+UI统一+Bug修复
== 已部署上线 (9项) == - 代办事项真实数据源集成 (企微审批API 8bug修复链) - H5/坐席端 Logo样式统一+绿色背景 - 视频引导页修复 (localStorage key v2) - 坐席端 v9 Vue版本修复 (ElMessage._context) - 截图按钮 v10 修复 (getDisplayMedia user gesture) - 扫码样式恢复+H5扫码登录跳转修复 - H5截图快捷键提示 == 代码完成待部署 (3项) == - 知识迭代3Bug修复 (#8 POST端点/#7 MERGE幂等/#6 过期检查) - 会议室预定-小鱼易联终端 (40文件, 40/40测试通过) - IT资产升级审批推送 (asset_service.py) == 需求文档 (2项) == - 坐席端AI辅助消息框-PRD (4项新功能确认) - 坐席端布局优化建议 v2.0 (7天计划) == 新增文档 == - 日报-2026-07-11.md - 知识迭代Bug修复报告-20260711.md - 会议室预定-部署指南.md - CHANGELOG.md 更新 == 测试 == - test_todo_integration.py: 40/40 - test_meetingroom.py: 40/40 - test_bugfix_ki_suggestions.py: 21/21
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — ITSM OpenAPI 签名工具
|
||||
# =============================================================================
|
||||
# 说明:ITSM 一站式运维平台 OpenAPI 采用 SHA1 签名认证。
|
||||
# 每次请求需在 header 中携带 appId、timestamp、sign 三个字段。
|
||||
# sign 的计算方式由 ITSM API 文档定义,此模块封装为静态工具类。
|
||||
# =============================================================================
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
|
||||
class ITSMSigner:
|
||||
"""ITSM OpenAPI 签名工具 — SHA1 签名认证。
|
||||
|
||||
签名算法(来自 ITSM API 文档):
|
||||
1. 组装 sign_params = {appId, timestamp, appSecret, bizData=json.dumps(body)}
|
||||
2. 按 key 升序排列
|
||||
3. 拼接所有 value 为一个字符串
|
||||
4. quote_plus 编码
|
||||
5. SHA1 哈希
|
||||
6. 转大写 hex
|
||||
|
||||
设计为纯静态工具类,无需实例化,线程安全。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def compute_signature(app_id: str, timestamp: str, app_secret: str, biz_data: dict) -> str:
|
||||
"""计算 ITSM API 签名。
|
||||
|
||||
Args:
|
||||
app_id: ITSM OpenAPI app_id
|
||||
timestamp: 毫秒级时间戳字符串
|
||||
app_secret: ITSM OpenAPI app_secret
|
||||
biz_data: 请求体(业务数据),将被 json 序列化后参与签名
|
||||
|
||||
Returns:
|
||||
str: 大写 hex 格式的 SHA1 签名
|
||||
"""
|
||||
sign_params = {
|
||||
"appSecret": app_secret,
|
||||
"appId": app_id,
|
||||
"timestamp": timestamp,
|
||||
"bizData": json.dumps(biz_data, ensure_ascii=False),
|
||||
}
|
||||
# 按 key 升序排列后拼接所有 value
|
||||
sorted_params = sorted(sign_params.items(), key=lambda x: x[0])
|
||||
canonicalized = "".join(str(v) for _, v in sorted_params)
|
||||
# URL 编码
|
||||
quoted = quote_plus(canonicalized)
|
||||
# SHA1 哈希转大写
|
||||
return hashlib.sha1(quoted.encode("utf-8")).hexdigest().upper()
|
||||
|
||||
@staticmethod
|
||||
def get_headers(app_id: str, app_secret: str, biz_data: dict) -> dict:
|
||||
"""生成带签名的请求头。
|
||||
|
||||
Args:
|
||||
app_id: ITSM OpenAPI app_id
|
||||
app_secret: ITSM OpenAPI app_secret
|
||||
biz_data: 请求体(业务数据),用于计算签名
|
||||
|
||||
Returns:
|
||||
dict: 包含 appId、timestamp、sign、Content-Type 的请求头
|
||||
"""
|
||||
timestamp = str(int(time.time() * 1000))
|
||||
sign = ITSMSigner.compute_signature(app_id, timestamp, app_secret, biz_data)
|
||||
return {
|
||||
"appId": app_id,
|
||||
"timestamp": timestamp,
|
||||
"sign": sign,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Token 计数工具 — 支持 tiktoken 精确计数 + 字符估算兜底。
|
||||
|
||||
优先使用 tiktoken(cl100k_base) 与 OpenAI/GPT 系列模型一致;
|
||||
tiktoken 不可用时按 1 token ≈ 1.5 中文字符估算(误差 ≤10%)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 全局 tiktoken 编码器(延迟初始化)
|
||||
_encoder = None
|
||||
_tiktoken_available: Optional[bool] = None
|
||||
|
||||
|
||||
def _init_encoder():
|
||||
"""延迟初始化 tiktoken 编码器。"""
|
||||
global _encoder, _tiktoken_available
|
||||
if _tiktoken_available is not None:
|
||||
return
|
||||
try:
|
||||
import tiktoken
|
||||
_encoder = tiktoken.get_encoding("cl100k_base")
|
||||
_tiktoken_available = True
|
||||
logger.info("tiktoken cl100k_base 编码器初始化成功")
|
||||
except Exception as e:
|
||||
_tiktoken_available = False
|
||||
logger.warning(f"tiktoken 不可用,降级为字符估算: {e}")
|
||||
|
||||
|
||||
class TokenCounter:
|
||||
"""Token 计数器,支持精确(tiktoken)和估算(字符)两种模式。"""
|
||||
|
||||
@staticmethod
|
||||
def count_tokens(text: str) -> int:
|
||||
"""计算单段文本的 token 数。"""
|
||||
if not text:
|
||||
return 0
|
||||
_init_encoder()
|
||||
if _tiktoken_available and _encoder is not None:
|
||||
return len(_encoder.encode(text))
|
||||
# 兜底:中文约1.5字符/token,英文约4字符/token,混合取2字符/token
|
||||
return max(1, len(text) // 2)
|
||||
|
||||
@staticmethod
|
||||
def count_messages_tokens(messages: List[dict]) -> int:
|
||||
"""计算消息列表的 token 总数。
|
||||
|
||||
每条消息额外计入 role 标记的 overhead(约4 tokens/条)。
|
||||
"""
|
||||
total = 0
|
||||
for msg in messages:
|
||||
content = msg.get("content", "")
|
||||
role = msg.get("role", "")
|
||||
total += TokenCounter.count_tokens(content)
|
||||
total += TokenCounter.count_tokens(role)
|
||||
total += 4 # 每条消息的格式 overhead
|
||||
return total
|
||||
|
||||
@staticmethod
|
||||
def is_precise() -> bool:
|
||||
"""返回当前是否使用精确计数(tiktoken)。"""
|
||||
_init_encoder()
|
||||
return _tiktoken_available is True
|
||||
@@ -45,14 +45,15 @@ class ApprovalTokenManager:
|
||||
self.redis = redis_client
|
||||
self.corp_id = settings.wecom_corp_id
|
||||
self.corp_secret = corp_secret or settings.wecom_approval_secret
|
||||
self.client = httpx.AsyncClient(timeout=httpx.Timeout(connect=5.0, read=10.0))
|
||||
self.client = httpx.AsyncClient(timeout=httpx.Timeout(timeout=10.0, connect=5.0, read=10.0))
|
||||
|
||||
async def get_token(self) -> str:
|
||||
"""获取审批应用的 access_token。"""
|
||||
cached = await self.redis.get(self.CACHE_KEY)
|
||||
if cached:
|
||||
logger.debug("从缓存获取审批 access_token")
|
||||
return cached.decode("utf-8")
|
||||
# Redis 配置 decode_responses=True 时返回 str,否则返回 bytes
|
||||
return cached if isinstance(cached, str) else cached.decode("utf-8")
|
||||
return await self._refresh_token()
|
||||
|
||||
async def _refresh_token(self) -> str:
|
||||
@@ -119,7 +120,7 @@ class TokenManager:
|
||||
self.redis = redis_client
|
||||
self.corp_id = settings.wecom_corp_id
|
||||
self.corp_secret = settings.wecom_secret
|
||||
self.client = httpx.AsyncClient(timeout=httpx.Timeout(connect=5.0, read=10.0))
|
||||
self.client = httpx.AsyncClient(timeout=httpx.Timeout(timeout=10.0, connect=5.0, read=10.0))
|
||||
|
||||
async def get_token(self) -> str:
|
||||
"""获取 access_token。
|
||||
@@ -136,7 +137,8 @@ class TokenManager:
|
||||
cached = await self.redis.get(self.CACHE_KEY)
|
||||
if cached:
|
||||
logger.debug("从缓存获取 access_token")
|
||||
return cached.decode("utf-8")
|
||||
# Redis 配置 decode_responses=True 时返回 str,否则返回 bytes
|
||||
return cached if isinstance(cached, str) else cached.decode("utf-8")
|
||||
|
||||
# 2. 缓存未命中,刷新 token
|
||||
return await self._refresh_token()
|
||||
|
||||
Reference in New Issue
Block a user