Files
more_dots/services/storage/message_storage.py
T

886 lines
32 KiB
Python
Raw Normal View History

2026-03-24 18:07:22 +08:00
"""
消息存储服务 - 将每次查询的消息记录存储到 MySQL
"""
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional
import pymysql
from config import Config
from services.common.datetime_utils import DateTimeGenerator
from schemas.messages import MessagesDTO
class MessageStorage:
"""消息存储服务"""
def __init__(self):
cfg = Config.get_section("logging_mysql")
self.enabled = str(cfg.get("enabled", "false")).lower() in ("1", "true", "yes")
self.entity_debug_enabled = str(cfg.get("entity_debug_enabled", "false")).lower() in ("1", "true", "yes")
self.host = cfg.get("host", "127.0.0.1")
self.port = int(cfg.get("port", 3306))
self.user = cfg.get("user", "root")
self.password = cfg.get("password", "")
self.database = cfg.get("database", "more_dots")
# 消息落库与结构化日志分表,避免误用 logging_mysql.table=structured_logs
self.table = cfg.get("messages_table", "ipc_apbo.messages")
self.conversation_table = cfg.get("conversation_table", "ipc_apbo.conversations")
self.connect_timeout = int(cfg.get("connect_timeout", 5))
self._inited = False
self._conversation_schema_checked = False
def _get_conn(self):
"""获取数据库连接"""
return pymysql.connect(
host=self.host,
port=self.port,
user=self.user,
password=self.password,
database=self.database,
charset="utf8mb4",
autocommit=True,
connect_timeout=self.connect_timeout,
)
@staticmethod
def _log_local(event: str, payload: Optional[Dict[str, Any]] = None) -> None:
print(json.dumps({
"level": "ERROR",
"event": event,
"payload": payload or {},
"created_at": DateTimeGenerator.now().epoch_millis,
}, ensure_ascii=False))
def _log_entity_debug(self, event: str, payload: Optional[Dict[str, Any]] = None) -> None:
if not self.entity_debug_enabled:
return
print(json.dumps({
"level": "DEBUG",
"event": event,
"payload": payload or {},
"created_at": DateTimeGenerator.now().epoch_millis,
}, ensure_ascii=False))
@staticmethod
def _now_ms() -> int:
return DateTimeGenerator.now().epoch_millis
@staticmethod
def _build_audit_fields(
*,
random_code: str,
user: Optional[str],
created_value: Any = None,
updated_value: Any = None,
) -> Dict[str, Any]:
created_ms = MessageStorage._resolve_epoch_millis(created_value)
updated_ms = MessageStorage._resolve_epoch_millis(updated_value, fallback=created_ms)
created_bundle = DateTimeGenerator.bundle(created_ms, default_to_now=True)
updated_bundle = DateTimeGenerator.bundle(updated_ms, default_to_now=True)
operator = (user or "system").strip() if isinstance(user, str) else "system"
return {
"random_code": random_code,
"create_user": operator,
"create_date": created_bundle.db_datetime,
"update_user": operator,
"update_date": updated_bundle.db_datetime,
"create_user_name": operator,
"update_user_name": operator,
"created_at": created_ms,
"updated_at": updated_ms,
}
@staticmethod
def _resolve_epoch_millis(value: Any, fallback: Optional[int] = None) -> int:
if value is None:
return fallback if fallback is not None else DateTimeGenerator.now().epoch_millis
if isinstance(value, (int, float)):
raw = int(value)
digits = len(str(abs(raw)))
if digits == 10:
return raw * 1000
return raw
if isinstance(value, str):
text = value.strip()
if text.isdigit():
raw = int(text)
digits = len(text)
if digits == 10:
return raw * 1000
if digits == 13:
return raw
parsed = DateTimeGenerator.bundle(value, default_to_now=True)
return parsed.epoch_millis
parsed = DateTimeGenerator.bundle(value, default_to_now=True)
return parsed.epoch_millis
def _log_entity_stage(self, entity: str, action: str, stage: str, started_at: int, payload: Optional[Dict[str, Any]] = None) -> None:
debug_payload = dict(payload or {})
debug_payload.update({
"entity": entity,
"action": action,
"stage": stage,
"elapsed_ms": max(0, self._now_ms() - started_at),
})
self._log_entity_debug(f"message_storage.{entity}.{action}.{stage}", debug_payload)
@staticmethod
def _split_table_reference(table_name: str, default_schema: str) -> tuple[str, str]:
cleaned = str(table_name or "").strip()
if "." in cleaned:
schema_name, physical_table_name = cleaned.split(".", 1)
else:
schema_name, physical_table_name = default_schema, cleaned
return schema_name.strip().strip("`"), physical_table_name.strip().strip("`")
def _ensure_conversation_schema(self, conn) -> None:
if self._conversation_schema_checked or not self.enabled:
return
started_at = self._now_ms()
self._log_entity_stage(
"conversations",
"ensure_schema",
"start",
started_at,
{"conversation_table": self.conversation_table},
)
schema_name, table_name = self._split_table_reference(self.conversation_table, self.database)
probe_sql = """
SELECT 1
FROM information_schema.columns
WHERE table_schema = %s AND table_name = %s AND column_name = %s
LIMIT 1
"""
with conn.cursor() as cur:
cur.execute(probe_sql, (schema_name, table_name, "name"))
if cur.fetchone() is None:
self._log_entity_stage(
"conversations",
"ensure_schema",
"alter_needed",
started_at,
{"conversation_table": self.conversation_table, "missing_column": "name"},
)
alter_sql = f"""
ALTER TABLE {self.conversation_table}
ADD COLUMN name VARCHAR(255) NULL COMMENT '会话名称' AFTER user
"""
cur.execute(alter_sql)
self._conversation_schema_checked = True
self._log_entity_stage(
"conversations",
"ensure_schema",
"success",
started_at,
{"conversation_table": self.conversation_table, "schema_checked": True},
)
def _ensure_table(self) -> None:
"""确保消息表存在"""
if self._inited or not self.enabled:
return
started_at = self._now_ms()
self._log_entity_stage(
"storage",
"ensure_table",
"start",
started_at,
{"messages_table": self.table, "conversation_table": self.conversation_table},
)
message_sql = f"""
CREATE TABLE IF NOT EXISTS {self.table} (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
random_code VARCHAR(100) NULL COMMENT '业务主键',
create_user VARCHAR(100) NULL COMMENT '创建人',
create_date DATETIME NULL COMMENT '创建时间',
update_user VARCHAR(100) NULL COMMENT '修改人',
update_date DATETIME NULL COMMENT '修改时间',
create_user_name VARCHAR(255) NULL COMMENT '创建人姓名',
update_user_name VARCHAR(255) NULL COMMENT '修改人姓名',
message_id VARCHAR(255) NULL COMMENT '消息 ID',
conversation_id VARCHAR(255) NULL COMMENT '会话 ID',
user VARCHAR(255) NULL COMMENT '用户标识',
query LONGTEXT NULL COMMENT '用户查询',
answer LONGTEXT NULL COMMENT '回答消息内容',
feedback VARCHAR(255) NULL COMMENT '点赞 like / 点踩 dislike',
feedback_content TEXT NULL COMMENT '点踩内容',
created_at BIGINT NULL COMMENT '创建时间(毫秒时间戳)',
updated_at BIGINT NULL COMMENT '更新时间(毫秒时间戳)',
`log` JSON NULL COMMENT '当前对话日志(JSON字符串)',
UNIQUE KEY uk_random_code (random_code),
UNIQUE KEY uk_message_id (message_id),
INDEX idx_conversation_id (conversation_id),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息记录表';
"""
conversation_sql = f"""
CREATE TABLE IF NOT EXISTS {self.conversation_table} (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
random_code VARCHAR(100) NULL COMMENT '业务主键',
create_user VARCHAR(100) NULL COMMENT '创建人',
create_date DATETIME NULL COMMENT '创建时间',
update_user VARCHAR(100) NULL COMMENT '修改人',
update_date DATETIME NULL COMMENT '修改时间',
create_user_name VARCHAR(255) NULL COMMENT '创建人姓名',
update_user_name VARCHAR(255) NULL COMMENT '修改人姓名',
conversation_id VARCHAR(255) NULL COMMENT '会话 ID',
user VARCHAR(255) NULL COMMENT '用户',
name VARCHAR(512) NULL COMMENT '会话名称',
status VARCHAR(255) NULL COMMENT '状态',
introduction VARCHAR(255) NULL COMMENT '开场白',
created_at BIGINT NULL COMMENT '创建时间(毫秒时间戳)',
updated_at BIGINT NULL COMMENT '更新时间(毫秒时间戳)',
UNIQUE KEY uk_conversation_random_code (random_code),
UNIQUE KEY uk_conversation_id (conversation_id),
INDEX idx_conversation_updated_at (updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会话记录表';
"""
try:
with self._get_conn() as conn:
with conn.cursor() as cur:
cur.execute(message_sql)
cur.execute(conversation_sql)
self._ensure_conversation_schema(conn)
self._inited = True
self._log_entity_stage(
"storage",
"ensure_table",
"success",
started_at,
{"messages_table": self.table, "conversation_table": self.conversation_table},
)
except Exception as exc:
self._log_entity_stage(
"storage",
"ensure_table",
"failed",
started_at,
{"messages_table": self.table, "conversation_table": self.conversation_table, "error": str(exc)},
)
self._log_local("message_storage.ensure_table_failed", {
"error": str(exc),
"messages_table": self.table,
"conversation_table": self.conversation_table,
})
# 开发阶段容错,避免初始化失败影响主流程
self.enabled = False
def create_conversation(
self,
conversation_id: str,
user: Optional[str],
name: Optional[str],
status: str,
introduction: Optional[str],
created_at: int,
updated_at: int,
) -> bool:
started_at = self._now_ms()
self._log_entity_stage(
"conversations",
"create",
"start",
started_at,
{
"conversation_id": conversation_id,
"user": user,
"name_len": len(name or ""),
"status": status,
},
)
if not self.enabled:
self._log_entity_stage(
"conversations",
"create",
"skipped",
started_at,
{"conversation_id": conversation_id, "reason": "storage_disabled"},
)
return False
self._ensure_table()
if not self.enabled:
self._log_entity_stage(
"conversations",
"create",
"skipped",
started_at,
{"conversation_id": conversation_id, "reason": "storage_disabled_after_init"},
)
return False
insert_sql = f"""
INSERT INTO {self.conversation_table}(
random_code, create_user, create_date, update_user, update_date,
create_user_name, update_user_name,
conversation_id, user, name, status, introduction, created_at, updated_at
) VALUES(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
"""
try:
audit = self._build_audit_fields(
random_code=conversation_id,
user=user,
created_value=created_at,
updated_value=updated_at,
)
with self._get_conn() as conn:
self._ensure_conversation_schema(conn)
with conn.cursor() as cur:
self._log_entity_stage(
"conversations",
"create",
"sql_execute",
started_at,
{"conversation_id": conversation_id, "table": self.conversation_table},
)
cur.execute(
insert_sql,
(
audit["random_code"],
audit["create_user"],
audit["create_date"],
audit["update_user"],
audit["update_date"],
audit["create_user_name"],
audit["update_user_name"],
conversation_id,
user,
name,
status,
introduction,
audit["created_at"],
audit["updated_at"],
),
)
self._log_entity_stage(
"conversations",
"create",
"success",
started_at,
{"conversation_id": conversation_id, "table": self.conversation_table},
)
return True
except Exception as exc:
self._log_entity_stage(
"conversations",
"create",
"failed",
started_at,
{"conversation_id": conversation_id, "table": self.conversation_table, "error": str(exc)},
)
self._log_local("message_storage.create_conversation_failed", {
"error": str(exc),
"conversation_table": self.conversation_table,
"conversation_id": conversation_id,
})
return False
def get_conversation_by_id(self, conversation_id: str) -> Optional[Dict[str, Any]]:
started_at = self._now_ms()
self._log_entity_stage(
"conversations",
"get_by_id",
"start",
started_at,
{"conversation_id": conversation_id},
)
if not self.enabled:
self._log_entity_stage(
"conversations",
"get_by_id",
"skipped",
started_at,
{"conversation_id": conversation_id, "reason": "storage_disabled"},
)
return None
self._ensure_table()
if not self.enabled:
self._log_entity_stage(
"conversations",
"get_by_id",
"skipped",
started_at,
{"conversation_id": conversation_id, "reason": "storage_disabled_after_init"},
)
return None
select_sql = f"""
SELECT conversation_id, user, name, status, introduction, created_at, updated_at
FROM {self.conversation_table}
WHERE conversation_id = %s
LIMIT 1
"""
try:
with self._get_conn() as conn:
self._ensure_conversation_schema(conn)
with conn.cursor(pymysql.cursors.DictCursor) as cur:
self._log_entity_stage(
"conversations",
"get_by_id",
"sql_execute",
started_at,
{"conversation_id": conversation_id, "table": self.conversation_table},
)
cur.execute(select_sql, (conversation_id,))
result = cur.fetchone()
self._log_entity_stage(
"conversations",
"get_by_id",
"success",
started_at,
{
"conversation_id": conversation_id,
"table": self.conversation_table,
"found": bool(result),
},
)
return dict(result) if result else None
except Exception as exc:
self._log_entity_stage(
"conversations",
"get_by_id",
"failed",
started_at,
{"conversation_id": conversation_id, "table": self.conversation_table, "error": str(exc)},
)
self._log_local("message_storage.get_conversation_failed", {
"error": str(exc),
"conversation_table": self.conversation_table,
"conversation_id": conversation_id,
})
return None
def update_conversation_updated_at(self, conversation_id: str, updated_at: int) -> bool:
started_at = self._now_ms()
self._log_entity_stage(
"conversations",
"update_updated_at",
"start",
started_at,
{"conversation_id": conversation_id, "updated_at": updated_at},
)
if not self.enabled:
self._log_entity_stage(
"conversations",
"update_updated_at",
"skipped",
started_at,
{"conversation_id": conversation_id, "reason": "storage_disabled"},
)
return False
self._ensure_table()
if not self.enabled:
self._log_entity_stage(
"conversations",
"update_updated_at",
"skipped",
started_at,
{"conversation_id": conversation_id, "reason": "storage_disabled_after_init"},
)
return False
update_sql = f"""
UPDATE {self.conversation_table}
SET updated_at = %s,
update_date = %s
WHERE conversation_id = %s
"""
try:
update_ms = self._resolve_epoch_millis(updated_at)
update_bundle = DateTimeGenerator.bundle(update_ms, default_to_now=True)
with self._get_conn() as conn:
self._ensure_conversation_schema(conn)
with conn.cursor() as cur:
self._log_entity_stage(
"conversations",
"update_updated_at",
"sql_execute",
started_at,
{"conversation_id": conversation_id, "table": self.conversation_table},
)
affected_rows = cur.execute(
update_sql,
(update_ms, update_bundle.db_datetime, conversation_id),
)
self._log_entity_stage(
"conversations",
"update_updated_at",
"success",
started_at,
{"conversation_id": conversation_id, "affected_rows": int(affected_rows or 0)},
)
return bool(affected_rows)
except Exception as exc:
self._log_entity_stage(
"conversations",
"update_updated_at",
"failed",
started_at,
{"conversation_id": conversation_id, "table": self.conversation_table, "error": str(exc)},
)
self._log_local("message_storage.update_conversation_failed", {
"error": str(exc),
"conversation_table": self.conversation_table,
"conversation_id": conversation_id,
})
return False
def save_message(
self,
conversation_id: str,
message_id: str,
query: str,
answer: Optional[str] = None,
workflow_type: Optional[str] = None,
user: Optional[str] = None,
sql_query: Optional[str] = None,
execution_result: Optional[Dict[str, Any]] = None,
metadata: Optional[Dict[str, Any]] = None,
created_at: Optional[int] = None,
updated_at: Optional[int] = None,
logs: Optional[List[str]] = None,
) -> bool:
started_at = self._now_ms()
self._log_entity_stage(
"messages",
"create",
"start",
started_at,
{
"conversation_id": conversation_id,
"message_id": message_id,
"query_len": len(query or ""),
"answer_len": len(answer or ""),
"workflow_type": workflow_type,
},
)
"""
保存消息记录
Args:
conversation_id: 会话 ID
message_id: 消息 ID
query: 用户查询
answer: AI 回复
workflow_type: 工作流类型 (conversation/tool_using)
user: 用户标识
sql_query: 生成的 SQL
execution_result: SQL 执行结果
metadata: 其他元数据
logs: 过程日志(兼容 Java saveMessageToDB)
Returns:
bool: 是否保存成功
"""
if not self.enabled:
self._log_entity_stage(
"messages",
"create",
"skipped",
started_at,
{"conversation_id": conversation_id, "message_id": message_id, "reason": "storage_disabled"},
)
return False
self._ensure_table()
if not self.enabled:
self._log_entity_stage(
"messages",
"create",
"skipped",
started_at,
{"conversation_id": conversation_id, "message_id": message_id, "reason": "storage_disabled_after_init"},
)
return False
audit = self._build_audit_fields(
random_code=message_id,
user=user,
created_value=created_at,
updated_value=updated_at,
)
log_payload = {
"workflow_type": workflow_type,
"sql_query": sql_query,
"execution_result": execution_result or {},
"metadata": metadata or {},
}
normalized_logs = [str(item) for item in (logs or []) if str(item).strip()]
if normalized_logs:
log_payload["data"] = "\n".join(normalized_logs)
message_record = MessagesDTO(
message_id=message_id,
conversation_id=conversation_id,
user=user,
query=query,
answer=answer,
feedback=None,
feedback_content=None,
created_at=audit["created_at"],
updated_at=audit["updated_at"],
log=log_payload,
)
insert_sql = f"""
INSERT INTO {self.table}(
random_code, create_user, create_date, update_user, update_date,
create_user_name, update_user_name,
message_id, conversation_id, user, query, answer,
feedback, feedback_content, created_at, updated_at, `log`
) VALUES(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
"""
try:
with self._get_conn() as conn:
with conn.cursor() as cur:
self._log_entity_stage(
"messages",
"create",
"sql_execute",
started_at,
{"conversation_id": conversation_id, "message_id": message_id, "table": self.table},
)
cur.execute(
insert_sql,
(
audit["random_code"],
audit["create_user"],
audit["create_date"],
audit["update_user"],
audit["update_date"],
audit["create_user_name"],
audit["update_user_name"],
message_record.message_id,
message_record.conversation_id,
message_record.user,
message_record.query,
message_record.answer,
message_record.feedback,
message_record.feedback_content,
message_record.created_at,
message_record.updated_at,
json.dumps(message_record.log, ensure_ascii=False),
),
)
self._log_entity_stage(
"messages",
"create",
"success",
started_at,
{"conversation_id": conversation_id, "message_id": message_id, "table": self.table},
)
return True
except Exception as exc:
self._log_entity_stage(
"messages",
"create",
"failed",
started_at,
{"conversation_id": conversation_id, "message_id": message_id, "table": self.table, "error": str(exc)},
)
self._log_local("message_storage.save_failed", {
"error": str(exc),
"messages_table": self.table,
"message_id": message_id,
"conversation_id": conversation_id,
})
# 开发阶段容错,避免日志失败影响主流程
return False
def get_conversation_history(
self,
conversation_id: str,
limit: int = 20
) -> List[Dict[str, Any]]:
"""
获取会话历史消息
Args:
conversation_id: 会话 ID
limit: 返回消息数量
Returns:
List[Dict[str, Any]]: 消息列表
"""
started_at = self._now_ms()
self._log_entity_stage(
"messages",
"get_history",
"start",
started_at,
{"conversation_id": conversation_id, "limit": limit},
)
if not self.enabled:
self._log_entity_stage(
"messages",
"get_history",
"skipped",
started_at,
{"conversation_id": conversation_id, "reason": "storage_disabled"},
)
return []
self._ensure_table()
if not self.enabled:
self._log_entity_stage(
"messages",
"get_history",
"skipped",
started_at,
{"conversation_id": conversation_id, "reason": "storage_disabled_after_init"},
)
return []
select_sql = f"""
SELECT * FROM {self.table}
WHERE conversation_id = %s
ORDER BY created_at DESC
LIMIT %s
"""
try:
with self._get_conn() as conn:
with conn.cursor(pymysql.cursors.DictCursor) as cur:
self._log_entity_stage(
"messages",
"get_history",
"sql_execute",
started_at,
{"conversation_id": conversation_id, "limit": limit, "table": self.table},
)
cur.execute(select_sql, (conversation_id, limit))
results = cur.fetchall()
self._log_entity_stage(
"messages",
"get_history",
"success",
started_at,
{"conversation_id": conversation_id, "count": len(results or [])},
)
return list(results)
except Exception as exc:
self._log_entity_stage(
"messages",
"get_history",
"failed",
started_at,
{"conversation_id": conversation_id, "error": str(exc)},
)
return []
def update_feedback_by_message_id(
self,
message_id: str,
feedback: str,
feedback_content: Optional[str] = None,
) -> bool:
"""按 message_id 回写点赞/点踩反馈。"""
started_at = self._now_ms()
self._log_entity_stage(
"messages",
"update_feedback",
"start",
started_at,
{"message_id": message_id, "feedback": feedback},
)
if not self.enabled:
self._log_entity_stage(
"messages",
"update_feedback",
"skipped",
started_at,
{"message_id": message_id, "reason": "storage_disabled"},
)
return False
self._ensure_table()
if not self.enabled:
self._log_entity_stage(
"messages",
"update_feedback",
"skipped",
started_at,
{"message_id": message_id, "reason": "storage_disabled_after_init"},
)
return False
update_sql = f"""
UPDATE {self.table}
SET feedback = %s,
feedback_content = %s,
updated_at = %s,
update_date = %s
WHERE message_id = %s
"""
normalized_feedback_content = (feedback_content or "").strip() or None
now_bundle = DateTimeGenerator.now()
try:
with self._get_conn() as conn:
with conn.cursor() as cur:
self._log_entity_stage(
"messages",
"update_feedback",
"sql_execute",
started_at,
{"message_id": message_id, "table": self.table},
)
affected_rows = cur.execute(
update_sql,
(
feedback,
normalized_feedback_content,
now_bundle.epoch_millis,
now_bundle.db_datetime,
message_id,
),
)
self._log_entity_stage(
"messages",
"update_feedback",
"success",
started_at,
{"message_id": message_id, "affected_rows": int(affected_rows or 0)},
)
return bool(affected_rows)
except Exception as exc:
self._log_entity_stage(
"messages",
"update_feedback",
"failed",
started_at,
{"message_id": message_id, "error": str(exc)},
)
# 开发阶段容错,避免日志失败影响主流程
return False
# 全局单例
_GLOBAL_MESSAGE_STORAGE: Optional[MessageStorage] = None
def get_message_storage() -> MessageStorage:
"""获取消息存储服务实例"""
global _GLOBAL_MESSAGE_STORAGE
if _GLOBAL_MESSAGE_STORAGE is None:
_GLOBAL_MESSAGE_STORAGE = MessageStorage()
return _GLOBAL_MESSAGE_STORAGE