feat: 新增反馈渠道字段、插件状态表与日志敏感信息脱敏

1. 为消息反馈表新增channel字段并完善相关接口返回
2. 创建渠道插件状态存储表与对应索引
3. 调整路由规则agent_config_id字段类型为字符串
4. 添加日志敏感信息脱敏功能
5. 优化消息记录仓库方法,新增响应时间统计和查询接口
This commit is contained in:
Kris 2026-05-13 16:50:28 +08:00
parent dd0ba81f66
commit 03d9dd0543
6 changed files with 127 additions and 14 deletions

View File

@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import timedelta from datetime import timedelta
from sqlalchemy import select, update from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.channels.models import ChannelMessage, ChannelResponse from yuxi.channels.models import ChannelMessage, ChannelResponse
@ -43,17 +43,21 @@ class ChannelMessageRecordRepository:
await self.db.refresh(record) await self.db.refresh(record)
return record return record
async def mark_success(self, record_id: int, response: ChannelResponse) -> None: async def mark_success(
await self.db.execute( self,
update(ChannelMsgRecord) record_id: int,
.where(ChannelMsgRecord.id == record_id) response: ChannelResponse,
.values( response_time_ms: int | None = None,
status="success", ) -> None:
reply_message_id=response.identity.channel_message_id, values: dict = {
reply_content_preview=self._truncate(response.content, MAX_REPLY_PREVIEW), "status": "success",
replied_at=utc_now_naive(), "reply_message_id": response.identity.channel_message_id,
) "reply_content_preview": self._truncate(response.content, MAX_REPLY_PREVIEW),
) "replied_at": utc_now_naive(),
}
if response_time_ms is not None:
values["response_time_ms"] = response_time_ms
await self.db.execute(update(ChannelMsgRecord).where(ChannelMsgRecord.id == record_id).values(**values))
await self.db.commit() await self.db.commit()
async def mark_error(self, record_id: int, error_message: str) -> None: async def mark_error(self, record_id: int, error_message: str) -> None:
@ -91,6 +95,33 @@ class ChannelMessageRecordRepository:
cutoff = utc_now_naive() - timedelta(seconds=timeout_seconds) cutoff = utc_now_naive() - timedelta(seconds=timeout_seconds)
return [r for r in records if r.created_at and r.created_at < cutoff] return [r for r in records if r.created_at and r.created_at < cutoff]
async def get_recent_records(
self, channel_id: str, chat_id: str, limit: int = 10
) -> list[ChannelMsgRecord]:
result = await self.db.execute(
select(ChannelMsgRecord)
.where(
ChannelMsgRecord.channel_id == channel_id,
ChannelMsgRecord.chat_id == chat_id,
)
.order_by(ChannelMsgRecord.created_at.desc())
.limit(limit)
)
return list(result.scalars().all())
async def get_chat_message_count(
self, channel_id: str, chat_id: str, since_hours: int = 24
) -> int:
cutoff = utc_now_naive() - timedelta(hours=since_hours)
result = await self.db.execute(
select(func.count()).select_from(ChannelMsgRecord).where(
ChannelMsgRecord.channel_id == channel_id,
ChannelMsgRecord.chat_id == chat_id,
ChannelMsgRecord.created_at >= cutoff,
)
)
return result.scalar() or 0
@staticmethod @staticmethod
def _truncate(text: str, max_length: int) -> str: def _truncate(text: str, max_length: int) -> str:
if len(text) <= max_length: if len(text) <= max_length:

View File

@ -12,6 +12,7 @@ async def submit_message_feedback_view(
message_id: int, message_id: int,
rating: str, rating: str,
reason: str | None, reason: str | None,
channel: str = "webchat",
db: AsyncSession, db: AsyncSession,
current_user_id: str, current_user_id: str,
) -> dict: ) -> dict:
@ -41,6 +42,7 @@ async def submit_message_feedback_view(
user_id=str(current_user_id), user_id=str(current_user_id),
rating=rating, rating=rating,
reason=reason, reason=reason,
channel=channel,
) )
db.add(new_feedback) db.add(new_feedback)
@ -54,6 +56,7 @@ async def submit_message_feedback_view(
"message_id": new_feedback.message_id, "message_id": new_feedback.message_id,
"rating": new_feedback.rating, "rating": new_feedback.rating,
"reason": new_feedback.reason, "reason": new_feedback.reason,
"channel": new_feedback.channel,
"created_at": new_feedback.created_at.isoformat(), "created_at": new_feedback.created_at.isoformat(),
} }
@ -86,6 +89,7 @@ async def get_message_feedback_view(
"id": feedback.id, "id": feedback.id,
"rating": feedback.rating, "rating": feedback.rating,
"reason": feedback.reason, "reason": feedback.reason,
"channel": feedback.channel,
"created_at": feedback.created_at.isoformat(), "created_at": feedback.created_at.isoformat(),
}, },
} }

View File

@ -364,6 +364,24 @@ class PostgresManager(metaclass=SingletonMeta):
"CREATE INDEX IF NOT EXISTS idx_msg_records_stuck" "CREATE INDEX IF NOT EXISTS idx_msg_records_stuck"
" ON channels_msg_records(channel_id, created_at) WHERE status = 'processing'", " ON channels_msg_records(channel_id, created_at) WHERE status = 'processing'",
"CREATE INDEX IF NOT EXISTS idx_msg_records_chat ON channels_msg_records(channel_id, chat_id)", "CREATE INDEX IF NOT EXISTS idx_msg_records_chat ON channels_msg_records(channel_id, chat_id)",
"""
CREATE TABLE IF NOT EXISTS channel_plugin_states (
id BIGSERIAL PRIMARY KEY,
channel_id VARCHAR(32) NOT NULL,
namespace VARCHAR(64) NOT NULL DEFAULT 'default',
entry_key VARCHAR(128) NOT NULL,
value_json JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
CONSTRAINT uq_channel_state UNIQUE (channel_id, namespace, entry_key)
)
""",
"CREATE INDEX IF NOT EXISTS idx_plugin_states_expiry"
" ON channel_plugin_states(expires_at) WHERE expires_at IS NOT NULL",
"CREATE INDEX IF NOT EXISTS idx_plugin_states_channel ON channel_plugin_states(channel_id, namespace)",
"ALTER TABLE IF EXISTS channels_routing_rules"
" ALTER COLUMN agent_config_id TYPE VARCHAR(64) USING agent_config_id::VARCHAR",
] ]
async with self.async_engine.begin() as conn: async with self.async_engine.begin() as conn:
for stmt in stmts: for stmt in stmts:

View File

@ -412,6 +412,9 @@ class MessageFeedback(Base):
user_id = Column(String(64), nullable=False, index=True, comment="User ID who provided feedback") user_id = Column(String(64), nullable=False, index=True, comment="User ID who provided feedback")
rating = Column(String(10), nullable=False, comment="Feedback rating: like or dislike") rating = Column(String(10), nullable=False, comment="Feedback rating: like or dislike")
reason = Column(Text, nullable=True, comment="Optional reason for dislike feedback") reason = Column(Text, nullable=True, comment="Optional reason for dislike feedback")
channel = Column(
String(32), nullable=False, default="webchat", comment="Feedback source channel: webchat/wechat/telegram/..."
)
created_at = Column(DateTime, default=utc_now_naive, comment="Feedback creation time") created_at = Column(DateTime, default=utc_now_naive, comment="Feedback creation time")
# Relationships # Relationships
@ -424,6 +427,7 @@ class MessageFeedback(Base):
"user_id": self.user_id, "user_id": self.user_id,
"rating": self.rating, "rating": self.rating,
"reason": self.reason, "reason": self.reason,
"channel": self.channel,
"created_at": format_utc_datetime(self.created_at), "created_at": format_utc_datetime(self.created_at),
} }

View File

@ -1,4 +1,4 @@
"""多渠道网关 ORM 模型 - 渠道用户映射、会话映射、消息记录""" """多渠道网关 ORM 模型 - 渠道用户映射、会话映射、消息记录、插件状态"""
from typing import Any from typing import Any
@ -10,6 +10,7 @@ from sqlalchemy import (
Integer, Integer,
String, String,
UniqueConstraint, UniqueConstraint,
text,
) )
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from yuxi.storage.postgres.models_business import Base from yuxi.storage.postgres.models_business import Base
@ -181,7 +182,7 @@ class ChannelRoutingRule(Base):
id = Column(BigInteger, primary_key=True, autoincrement=True) id = Column(BigInteger, primary_key=True, autoincrement=True)
channel_id = Column(String(32), nullable=False) channel_id = Column(String(32), nullable=False)
command = Column(String(64), nullable=False) command = Column(String(64), nullable=False)
agent_config_id = Column(Integer, nullable=False) agent_config_id = Column(String(64), nullable=False)
priority = Column(Integer, nullable=False, default=0) priority = Column(Integer, nullable=False, default=0)
created_at = Column(DateTime, nullable=False, default=utc_now_naive) created_at = Column(DateTime, nullable=False, default=utc_now_naive)
updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive)
@ -201,3 +202,36 @@ class ChannelRoutingRule(Base):
"created_at": format_utc_datetime(self.created_at), "created_at": format_utc_datetime(self.created_at),
"updated_at": format_utc_datetime(self.updated_at), "updated_at": format_utc_datetime(self.updated_at),
} }
class ChannelPluginState(Base):
"""渠道插件运行时状态统一存储"""
__tablename__ = "channel_plugin_states"
id = Column(BigInteger, primary_key=True, autoincrement=True)
channel_id = Column(String(32), nullable=False)
namespace = Column(String(64), nullable=False, default="default")
entry_key = Column(String(128), nullable=False)
value_json = Column(JSONB, nullable=False, default=dict)
created_at = Column(DateTime, nullable=False, default=utc_now_naive)
updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive)
expires_at = Column(DateTime, nullable=True)
__table_args__ = (
UniqueConstraint("channel_id", "namespace", "entry_key", name="uq_channel_state"),
Index("idx_plugin_states_expiry", "expires_at", postgresql_where=(text("expires_at IS NOT NULL"))),
Index("idx_plugin_states_channel", "channel_id", "namespace"),
)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"channel_id": self.channel_id,
"namespace": self.namespace,
"entry_key": self.entry_key,
"value_json": self.value_json or {},
"created_at": format_utc_datetime(self.created_at),
"updated_at": format_utc_datetime(self.updated_at),
"expires_at": format_utc_datetime(self.expires_at) if self.expires_at else None,
}

View File

@ -1,5 +1,6 @@
import logging import logging
import os import os
import re
import sys import sys
from loguru import logger as loguru_logger from loguru import logger as loguru_logger
@ -10,6 +11,25 @@ SAVE_DIR = os.getenv("SAVE_DIR") or "saves"
DATETIME = shanghai_now().strftime("%Y-%m-%d") DATETIME = shanghai_now().strftime("%Y-%m-%d")
LOG_FILE = f"{SAVE_DIR}/logs/yuxi-{DATETIME}.log" LOG_FILE = f"{SAVE_DIR}/logs/yuxi-{DATETIME}.log"
_SENSITIVE_RE_PATTERNS = [
(re.compile(r'(token[=:]\s*["\']?)([^\s"\'&,]+)', re.IGNORECASE), r"\1***"),
(re.compile(r'(secret[=:]\s*["\']?)([^\s"\'&,]+)', re.IGNORECASE), r"\1***"),
(re.compile(r'(password[=:]\s*["\']?)([^\s"\'&,]+)', re.IGNORECASE), r"\1***"),
(re.compile(r'(api_key[=:]\s*["\']?)([^\s"\'&,]+)', re.IGNORECASE), r"\1***"),
(re.compile(r"(Authorization:\s*)(Bearer\s+\S+)", re.IGNORECASE), r"\1***"),
(re.compile(r"(X-API-Key:\s*)(\S+)", re.IGNORECASE), r"\1***"),
(re.compile(r"'(app_secret|app_key|bot_token|access_token)':\s*'([^']+)'"), r"'\1': '***'"),
(re.compile(r'"(app_secret|app_key|bot_token|access_token)":\s*"([^"]+)"'), r'"\1": "***"'),
]
def _redact_sensitive_in_message(record: dict) -> None:
message = record.get("message", "")
if isinstance(message, str):
for pattern, replacement in _SENSITIVE_RE_PATTERNS:
message = pattern.sub(replacement, message)
record["message"] = message
class LoguruHandler(logging.Handler): class LoguruHandler(logging.Handler):
"""将 Python logging 桥接到 loguru 的 handler""" """将 Python logging 桥接到 loguru 的 handler"""
@ -69,6 +89,7 @@ def setup_logger(name, level="DEBUG", console=True):
retention="30 days", retention="30 days",
compression="zip", compression="zip",
enqueue=True, enqueue=True,
filter=_redact_sensitive_in_message,
) )
# 添加控制台日志(有颜色) # 添加控制台日志(有颜色)
@ -84,6 +105,7 @@ def setup_logger(name, level="DEBUG", console=True):
), ),
colorize=True, colorize=True,
enqueue=True, enqueue=True,
filter=_redact_sensitive_in_message,
) )
return loguru_logger return loguru_logger