Files
zhuangang/topics/backend/mongodb/examples/conversations/mongo_demo.py
T

133 lines
6.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# requires: pymongo
"""MongoDB:AI 客服会话存储——Schema 校验、复合索引与 explain、聚合统计、幂等 upsert、事务、变更流。
运行:MONGO_URL="mongodb://127.0.0.1:27017/?directConnection=true" python mongo_demo.py
需要 MongoDB 以副本集方式运行(事务和变更流都要求副本集;单节点副本集即可)。没有设置环境变量时跳过。
使用一个随机名字的临时数据库,结束后删除。
"""
import os
import sys
import uuid
from datetime import datetime, timedelta, timezone
from pymongo import ASCENDING, DESCENDING, MongoClient
from pymongo.errors import DuplicateKeyError, WriteError
url = os.environ.get("MONGO_URL")
if not url:
print("未设置 MONGO_URL,跳过")
sys.exit(0)
client = MongoClient(url, serverSelectionTimeoutMS=3000)
db = client[f"learn_{uuid.uuid4().hex[:8]}"]
def plan_stages(plan: dict) -> set[str]:
"""递归收集 explain 结果中的执行阶段名(IXSCAN / COLLSCAN / FETCH ...)。"""
out = {plan.get("stage", "")}
for key in ("inputStage", "queryPlan"):
if key in plan:
out |= plan_stages(plan[key])
for child in plan.get("inputStages", []):
out |= plan_stages(child)
return out
try:
# 1. Schema 校验:文档模型灵活,但关键字段仍要约束
db.create_collection("conversations", validator={"$jsonSchema": {
"bsonType": "object",
"required": ["tenant_id", "user_id", "status", "created_at", "messages"],
"properties": {
"tenant_id": {"bsonType": "string"},
"status": {"enum": ["open", "handoff", "closed"]},
"messages": {"bsonType": "array", "maxItems": 200}, # 嵌入数组要设上限,防止文档无限增长(单文档上限 16MB)
},
}})
conv = db.conversations
try:
conv.insert_one({"tenant_id": "t1", "user_id": "u1", "status": "unknown", "created_at": datetime.now(timezone.utc), "messages": []})
raise AssertionError("非法 status 应该被拒绝")
except WriteError:
print("1. Schema 校验:非法 status 被拒绝")
# 2. 写入数据:消息嵌入在会话文档中(一起读、一起写,数量有上限)
now = datetime.now(timezone.utc)
docs = []
for i in range(300):
tenant = f"t{i % 3}"
docs.append({
"tenant_id": tenant, "user_id": f"u{i % 50}",
"status": ["open", "handoff", "closed"][i % 3],
"created_at": now - timedelta(minutes=i),
"channel": "web" if i % 2 else "app",
"messages": [{"role": "user", "text": f"问题{i}"}, {"role": "assistant", "text": f"回答{i}", "tokens": 20 + i % 7}],
})
conv.insert_many(docs)
# 3. 复合索引:遵循 ESR(等值 Equality → 排序 Sort → 范围 Range)
conv.create_index([("tenant_id", ASCENDING), ("status", ASCENDING), ("created_at", DESCENDING)], name="tenant_status_time")
query = {"tenant_id": "t1", "status": "handoff"}
plan = conv.find(query).sort("created_at", DESCENDING).limit(10).explain()["queryPlanner"]["winningPlan"]
stages = plan_stages(plan)
assert "IXSCAN" in stages and "COLLSCAN" not in stages and "SORT" not in stages, stages
print(f"2. 查询走索引且无需内存排序:{sorted(s for s in stages if s)}")
# 4. 聚合:每个租户每个渠道的会话数和平均 token
pipeline = [
{"$match": {"created_at": {"$gte": now - timedelta(hours=24)}}},
{"$unwind": "$messages"},
{"$match": {"messages.role": "assistant"}},
{"$group": {"_id": {"tenant": "$tenant_id", "channel": "$channel"}, "conversations": {"$sum": 1}, "avg_tokens": {"$avg": "$messages.tokens"}}},
{"$sort": {"_id.tenant": 1, "_id.channel": 1}},
]
rows = list(conv.aggregate(pipeline))
assert sum(r["conversations"] for r in rows) == 300
print(f"3. 聚合统计 {len(rows)} 组,例如 {rows[0]['_id']} 共 {rows[0]['conversations']} 个会话,平均 {rows[0]['avg_tokens']:.1f} tokens")
# 5. 幂等写入:外部消息 id 做唯一索引 + upsert,重复投递不会产生重复数据
events = db.events
events.create_index("event_id", unique=True)
for _ in range(3): # 同一条消息投递 3 次
events.update_one({"event_id": "evt-001"}, {"$setOnInsert": {"payload": "退款申请", "at": now}}, upsert=True)
assert events.count_documents({"event_id": "evt-001"}) == 1
try:
events.insert_one({"event_id": "evt-001"})
raise AssertionError("唯一索引应该拒绝")
except DuplicateKeyError:
pass
print("4. 幂等 upsert:重复投递 3 次只有 1 条")
# 6. 多文档事务:转人工时同时更新会话和创建工单,要么都成功要么都失败
tickets = db.tickets
target = conv.find_one({"status": "open"})
assert target is not None
with client.start_session() as s:
with s.start_transaction():
conv.update_one({"_id": target["_id"]}, {"$set": {"status": "handoff"}}, session=s)
tickets.insert_one({"conversation_id": target["_id"], "tenant_id": target["tenant_id"], "state": "new"}, session=s)
assert conv.find_one({"_id": target["_id"]})["status"] == "handoff" and tickets.count_documents({}) == 1
try:
with client.start_session() as s:
with s.start_transaction():
tickets.insert_one({"conversation_id": "x", "state": "new"}, session=s)
raise RuntimeError("模拟中途失败")
except RuntimeError:
pass
assert tickets.count_documents({}) == 1
print("5. 事务:成功的提交生效,失败的整体回滚")
# 7. 变更流:数据同步到搜索引擎 / 缓存的常用手段(可以用 resume token 断点续传)
with tickets.watch([{"$match": {"operationType": "insert"}}]) as stream:
tickets.insert_one({"conversation_id": "y", "tenant_id": "t2", "state": "new"})
change = stream.try_next()
while change is None:
change = stream.try_next()
token = stream.resume_token
assert change["fullDocument"]["conversation_id"] == "y" and token is not None
print("6. 变更流收到插入事件,并拿到 resume token 用于断点续传")
print("全部检查通过")
finally:
client.drop_database(db.name)
client.close()