☰
多商户多语言客服系统实战:租户隔离、会话链路与语言包设计
2026/10/22 15:56:14 网站建设 项目流程

简介:智优客服 2.0 是一套基于 ThinkPHP6 + Swoole + PHP8 开发的多语言 SaaS 客服系统源码,面向需要快速搭建在线客服、多商户客服或聊天系统的开发者与企业,可满足私有化部署与小程序/H5/PC 等多端接入场景。代码支持多商户隔离、不限坐席、SSL 加密传输,聊天数据全部存储在自己的服务器,部署后即可直接运营,也适合对客服系统进行深度二次开发。压缩包共 2000 个文件,约 315MB,主要包含 PHP 后端业务逻辑、JS/CSS/HTML 前端界面、数据库 SQL 脚本、MD/TXT 说明文档以及图片素材等,进一步细看还有 layui 后台模板与 Swoole 相关配置,目录结构完整清晰。目前已有 289 人学习/浏览,代码覆盖从商家注册、坐席分配到支付接口等常见业务链;对想研究 Swoole 常驻内存架构、鉴权机制、界面定制或二次扩展的技术人员来说,是一份难得的实战源码参考。

1. 智优客服2.0:多语言、多商户、在线客服三个关键词,合起来才是真正的门槛

一个做跨境零售的某外贸公司在自家商城接入在线客服系统后,只撑了两天就发现问题:西语坐席看到的界面是西语,但访客端自动回复的欢迎语还是英文;商户A改了机器人话术,商户B的客户居然也收到了同一句话。多语言、多商户、在线客服这三件事拆开来看都有成熟方案,合在同一套系统里才是真正的坑。智优客服2.0这个标题所指向的,就是一个可被多商户共享的多语言客服SaaS平台:平台运营方、入驻商户、坐席、访客四种角色,在数据隔离的前提下共用同一条实时会话链路。这套东西要落地,顺序很关键——先解决租户隔离,再做会话链路,最后落多语言内容,沿途全是参数和边界问题。

2. 多商户SaaS租户隔离:从tenant_id注入到数据库路由的落地

多商户客服系统跟单商户系统的最大区别,是同一个进程里跑着N个商户,数据库表里的会话、坐席、话术、访客全部混在一起。租户隔离做不好,后面每一行代码都要返工。我实施这类多商户客服SaaS项目时,习惯按三步走:先定权限模型,再选数据隔离粒度,最后用中间件把tenant_id贯穿到所有业务查询。这一章讲的就是这三步,以及每一步里最容易漏掉的部分。

2.1 权限模型先分层:平台管理员、商户管理员、坐席各管各的边界

多商户客服系统里最常见的误区,是权限前后端各写一套。某公司在早期版本把"切换商户"功能做成了前端路由跳转,后端接口只校验登录状态,结果任意一个坐席改一下URL就能看到其它商户的会话列表,这是典型的垂直越权。正确做法是三层模型:平台管理员管理商户入驻和套餐启用,商户管理员只管理本商户的坐席成员和话术,坐席只能看到自己被分配的会话。角色之间的分界不是靠菜单隐藏,而是靠后端接口上的租户条件完成,前端菜单只是体验层。

数据模型上,必须先有"商户"维度再谈会话。常见表结构是merchant、agent、visitor、chat_session、message,其中chat_session和message直接带tenant_id和merchant_id两个字段。这里的tenant_id是平台分配给每个入驻商户的租户编号,后面所有查询都必须带它,包括管理员的运营报表。特别注意,平台方本身也是一种租户,管理端查询也会被同一个过滤器覆盖,否则后台的统计报表会把商户A和商户B的数据混在一起,老板看数据时还以为业务翻倍了。

还有一个参数细节要提前约定:tenant_id和merchant_id不是一回事。一个商户下可能有多个店铺或网点,此时商户等于租户,网点等于merchant_id,会话归属于网点。过滤条件必须tenant_id和merchant_id两层一起命中,缺一层就大概率越权。这个模型在项目第一天就要定死,不然后面加字段的血泪成本非常高。

2.2 数据库隔离选型:共享库加字段隔离,还是独立库独立schema

多商户SaaS的数据库隔离有三档:共享库共享表、共享库独立schema、独立库。客服系统的特点是消息量大但数据结构稳定,没有强金融合规要求,绝大多数场景我推荐共享库加tenant_id字段隔离。理由很实际:备份维护只面对一套库,跨商户运营统计一条SQL能出,开发期迁移成本最低。独立schema适合租户数据有强隔离要求的场景,但客服系统往往需要平台视角的跨商户会话分析,独立schema会把报表和定时任务变得非常痛苦。

下面是选型对比,可以直接拿去做评审材料:

方案隔离强度维护成本跨商户报表适合场景
共享库+tenant字段应用层隔离低简单客服SaaS默认方案
共享库+独立schema物理层隔离中需跨schema查询商户少、有合规要求
独立数据库物理层隔离高需汇聚层强合规、商户量少

实施细节上,业务表都要建tenant_id和merchant_id的联合索引,查询用AND条件过滤。这里要跟团队定死一条规矩:凡是写native SQL的报表、导出、定时任务,必须由统一查询构建器生成租户条件,禁止手写SQL时自己补条件。我接手的项目里,导出Excel模块临时拼SQL漏掉merchant_id,导致一次导出把全平台客户数据带出去的事故,不止一次发生。上线前我会专门做一次"去掉租户条件能查出什么"的扫描,把所有裸SQL揪出来。

2.3 用中间件把tenant_id注入请求上下文:一段可以直接套用的代码

租户条件不能靠每个接口手写一遍,常见做法是中间件自动注入。下面是我在服务端常用的实现,用ContextVar存租户上下文,避免多线程串号:

from contextvars import ContextVar import jwt # 用 ContextVar 保存当前请求的租户上下文,请求结束必须清理 tenant_ctx: ContextVar[str] = ContextVar("tenant_ctx", default="") @app.middleware("http") async def load_tenant(request: Request, call_next): # 登录、静态资源、webhook回调不归属任何租户,直接放行 if request.url.path.startswith(("/auth/", "/assets/", "/webhook/")): return await call_next(request) token = request.headers.get("Authorization", "").removeprefix("Bearer ") try: payload = jwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"]) # 把租户ID放进当前请求上下文,后续所有业务代码从这里读取 tenant_ctx.set(payload.get("tenant_id", "")) return await call_next(request) finally: # 必须清理,否则线程池复用时会读到上一个请求的租户 tenant_ctx.set("")

逻辑说明:中间件的作用是让"当前请求属于哪个租户"变成全局可访问状态,业务代码不再需要从token里反复解码。JWT里直接带tenant_id而不是每次查数据库,省掉一次查询,也让token本身成为租户凭证。两个细节决定成败:白名单要用前缀匹配,/auth/extend这类扩展接口也要覆盖;finally里的清理绝不能省,异步任务复用线程时不清空就会串租户,这是线上事故高发点。

参数说明:JWT_SECRET轮换会让所有存量token失效,生产环境要保留旧密钥做双密钥校验,等存量token自然过期再切。jwt.decode默认不校验过期时间,必须显式加上options={"verify_exp": True}。另外单商户期过渡到多商户期时,历史表里没有tenant_id的脏数据要写迁移脚本回填,常见做法是按merchant_id反查商户表补写。

即使有了ORM全局过滤器,复杂报表仍会绕过它。下面这条SQL就是典型的翻车点:

-- 会话列表:主表条件写对了,关联表条件容易被忽略 SELECT s.id, v.nickname, s.status, s.visitor_lang FROM chat_session s JOIN visitor v ON v.id = s.visitor_id WHERE s.tenant_id = %(tenant_id)s AND s.merchant_id = %(merchant_id)s ORDER BY s.updated_at DESC LIMIT 20 OFFSET %(offset)s;

这段SQL看起来多余,但报表和导出模块经常直接写SQL绕过ORM。JOIN表来自同一个租户时,如果只过滤了主表而没过滤关联表,结果集不一定立刻越权,可组合条件一变就会出事故。所以我会要求SQL层面再加一层租户条件,宁多勿少。验证租户隔离有个笨但有效的方法:用商户A的token请求商户B的资源,断言返回404或403,把这条用例写进自动化测试,每次发版自动跑。

3. 在线客服会话链路:WebSocket连接、消息幂等与坐席分配

客服系统的核心链路是"访客发起会话、消息双向实时推送、坐席分配、结束会话"。这一章的代码重点不在业务点,而在网络边界:WebSocket连接如何绑定租户和角色,消息如何防止重复和乱序,断线重连如何处理,坐席分配在并发下怎么保证公平。链路里的每一个选择都会直接影响访客体感和坐席效率。

3.1 访客身份与会话创建:幂等是第一步

访客第一次进聊天窗时,由前端生成visitor_id存localStorage,再次访问必须沿用同一个ID,否则刷新页面就变新访客。后端创建会话的幂等键是tenant_id+merchant_id+visitor_id+status=open,已存在未结束会话就直接返回原会话ID,而不是新建。这个幂等逻辑不做,刷新一次页面就会多一个空会话,坐席工作台很快被垃圾会话淹没。

def create_or_resume_session(tenant_id, merchant_id, visitor_id, visitor_lang): # 幂等查询:未结束会话优先复用,避免刷新页面就新建会话 exist = db.query(ChatSession).filter( ChatSession.tenant_id == tenant_id, ChatSession.merchant_id == merchant_id, ChatSession.visitor_id == visitor_id, ChatSession.status.in_(["open", "robot"]) ).first() if exist: return exist session = ChatSession( tenant_id=tenant_id, merchant_id=merchant_id, visitor_id=visitor_id, visitor_lang=visitor_lang, # 会话创建时写死,后续不随界面切换变化 status="robot", created_at=now_utc() ) db.add(session) db.commit() return session

参数说明:visitor_lang在创建时写死,是因为访客可能中途切换界面语言,但接待语言应当以会话创建时为准,否则坐席聊到一半发现语言变了,上下文直接断裂。status="robot"表示新会话优先由机器人接待,也可以根据商户开关默认置为unassigned,看被接待策略。幂等查询里status用in_(["open", "robot"]),把机器人接管中的人工排队状态也视为未结束。

还有一个比幂等更容易忽略的场景:机器人会话转人工时,如果另开一个会话,之前的聊天记录就断了。常见做法是chat_session表加session_type字段区分robot和agent,转人工时只把owner从机器人改成人工坐席,会话ID保持不变。这个设计直接影响坐席能不能看到上下文,后面踩坑章节还会专门讲。

3.2 WebSocket连接绑定:先鉴权,再绑定租户,再绑定角色

在线客服的实时通道常用WebSocket,落地时要守住一条底线:所有连接必须通过token鉴权后再绑定租户和用户身份,拒绝任何凭room号裸连的客户端。WebSocket是长连接,如果鉴权只放在HTTP握手层没做透彻,恶意连接可以订阅任意商户会话,后果比越权接口还严重。

@app.websocket("/ws/chat") async def ws_chat(websocket: WebSocket): # 校验token并取出身份,这一步失败直接拒绝连接 payload = verify_token(websocket.query_params.get("token", "")) if not payload: await websocket.close(code=4401) return tenant_id = payload["tenant_id"] user_id = payload["user_id"] role = payload["role"] # visitor / agent / admin # 访客只绑定自己的会话,坐席绑定所在商户的服务队列 if role == "visitor": session_id = websocket.query_params.get("session_id") bind_visitor_conn(session_id, tenant_id, user_id, websocket) elif role == "agent": bind_agent_conn(tenant_id, user_id, websocket) await websocket.accept() loop.send_heartbeat(websocket, interval=25)

关键参数:心跳间隔25秒,服务端60秒没收到pong就主动断开,防死连接占资源。token有效期建议不超过15分钟,WebSocket长连接配合refresh机制维持,别让一个token管几小时。bind_agent_conn成功后要同步把坐席状态置为online并写入Redis,这是分配算法的数据源。

心跳还有一个容易翻车的细节:浏览器后台标签页会冻结定时器,前端用setInterval发心跳靠不住。我一般会配合visibilitychange事件做空闲检测,页面切回来立刻补发一次心跳。前端断线重连时必须带上原session_id和client_msg_id,否则服务端无法判断是重连还是新连接,结果就是消息重复或连接泄漏。

3.3 坐席分配:空闲优先、语种匹配、业务组隔离

分配发生在两个时机:会话创建后的机器人接待阶段,以及访客点击"转人工"的瞬间。分配策略不建议在数据库里实时算,常见做法是把在线坐席缓存到Redis,在内存里跑分配算法。数据库算一次要带上在线状态、忙闲程度、语种、最近接待时间,并发一高就拖垮主库。

def assign_agent(session, online_agents): # 只拿出空闲坐席;超过最大接待数也算忙 idle_agents = [a for a in online_agents if a.status == "idle" and a.active_sessions < a.max_sessions] if not idle_agents: return None # 全部忙,返回排队位置 # 优先匹配访客语言,匹配不到再退回所有空闲坐席 matched = [a for a in idle_agents if session.visitor_lang in a.langs] pool = matched if matched else idle_agents # 同样空闲的按最久未接待排序,保证分配公平 pool.sort(key=lambda a: a.last_assigned_at or 0) return pool[0]

逻辑说明:分配算法的核心是"空闲优先、语种次之、公平兜底"。坐席的langs字段在个人资料里声明,一个坐席可以同时声明西语和英语,分配时做包含判断而不是等值判断。active_sessions是Redis里的计数器,坐席连接断开或会话结束就减一,不能实时查库,否则压测时数据库会被打爆。last_assigned_at排序保证没人被冷落,也避免某个坐席被连续砸单。

参数说明:max_sessions一般按坐席等级配置,普通坐席3个并发,资深坐席5个,这个参数要能在商户后台调整。分配失败返回None后,访客进入了排队状态,前端要轮询队列位置,轮询间隔建议3秒一次,太频繁会造成无谓的Redis压力。排队位置的计算用Redis的LLEN就能实现,别塞到业务表里。

多语言客服系统在分配这里还有一个特殊需求:访客语言和坐席语种不匹配时,即便该坐席空闲也不能分配,否则访客说西语坐席只会英语,体验直线下降。所以分配算法里"语种匹配"的优先级要排在"空闲优先"之后的第一位,保证只要有对应语种的空闲坐席就优先给这个人。

4. 多语言内容体系:语言包、翻译管理与机器人自动回复

多语言客服系统里的"多语言"不是给后台换个语言包那么简单,而是三件事同时成立:访客侧接待语言、坐席工作台语言、机器人自动回复语言。它们的数据源不同,生命周期也不同。这一章按三层拆解:语言包的存储结构、访客语言识别顺序、模板渲染与回退策略。

4.1 语言包的最小结构:key、文案和商户覆盖

多商户语言包的常见做法是平台提供基础语言包,商户可以覆盖自己店铺的话术。表结构落三张:message_key(key注册表)、language_dict(平台文案)、merchant_dict_override(商户覆盖文案)。key的粒度要按"一条用户可直接阅读的完整文案"走,而不是按单词,否则商户覆盖时根本不知道该改哪个片段。

{ "lang": "es", "welcome_msg": "¡Hola! ¿En qué podemos ayudarle?", "queue_tip": "Está en la posición {position} de la cola.", "offline_msg": "Fuera de horario. Deje su mensaje y le responderemos." }

说明:welcome_msg这类key直接落到message_key表,渲染时先查商户覆盖表,查不到再走平台基础包。覆盖是整条文案级覆盖,不允许商户只覆盖半句,否则会出现中西混杂的文案。这个约定要和商户后台的编辑UI绑定,前台表单是整条文本框,不是逐词翻译器。

为什么不建议直接塞自动翻译?机器翻译的质量在这种客服场景下只能当草稿,尤其是"排队人数""退款政策"这类业务敏感文案,错译会直接引发客诉。我一般把机器翻译作为运营的起点草稿,人工确认后写入language_dict,确认前一律走默认语言,这比全量自动翻译安全得多。

4.2 访客语言识别:参数、Cookie、浏览器语言、商户默认的顺序别搞反

访客语言识别的优先级我固定为:URL显式参数大于Cookie,Cookie大于浏览器Accept-Language,最后才是商户默认语言。这个顺序不能乱,因为三种来源的控制权归属不同。URL参数是营销投放时由活动链接带来的,Cookie是访客自己上次的选择,浏览器头是系统推测,商户默认只是兜底。

def detect_visitor_lang(request, merchant_allowed_langs): # 显式参数是营销投放最常见的来源,比如活动页 ?lang=es 打开 lang = request.args.get("lang") if lang in merchant_allowed_langs: return lang # Cookie 保存上次会话的选择,适合未登录访客 lang = request.cookies.get("visitor_lang") if lang in merchant_allowed_langs: return lang # 浏览器头部信息只取第一个语言代码,处理成小写再比对 accept_value = request.headers.get("Accept-Language", "en") parsed = accept_value.split(",")[0].lower() if parsed in merchant_allowed_langs: return parsed # 谁都不满足时用商户后台配置的默认语言 return merchant_default_lang(request)

参数说明:merchant_allowed_langs是商户开通的语种集合,不管来源是什么,不在集合里的一律不认。比如商户没开西语,访客浏览器再偏好西语也不能返回西语,否则界面出现没有语言包的死角。Accept-Language头里常见zh-CN、en-US这种带区域码的写法,比较前必须取第一部分并转小写。识别结果写入chat_session.visitor_lang,不要每次接口都重新识别,否则访客浏览器设置一变,坐席看到的语言也跟着变,会话上下文就被打乱了。

4.3 机器人自动回复的模板渲染与回退策略

机器人自动回复的数据是一套多语言模板,渲染时用占位符传参。典型模板是"{greeting},{visitor_nickname}。当前排队人数{queue_position}人",不同语言各自存一份。如果某个语言key缺失,常见做法是整段回退到默认语言,再补一条日志提示运营去补翻译。绝不要做逐词混合翻译——英文模板里突然夹一个西班牙语单词,访客体感极差,而且这种bug非常难复现。

def render_template(tpl_key, lang, params, default_lang="en"): # 先查商户覆盖,再查平台基础包 template = get_merchant_template(tpl_key, lang) or get_base_template(tpl_key, lang) if not template: template = get_base_template(tpl_key, default_lang) logger.warning("template_missing", key=tpl_key, lang=lang) # 简单replace即可;占位符风格统一为 {name} 避免混乱 for k, v in params.items(): template = template.replace("{" + k + "}", str(v)) return template

逻辑说明:render_template保证了每个语言key要么有商户文案,要么有平台文案,要么用默认语言整段替代,不存在混语言的可能。logger.warning是运营信号,上线初期每天捞一次missing模板日志,漏翻的文案就能统计出来。参数说明:params里不要放HTML片段,所有访客可见内容统一在输出层做转义,防止机器人话术里被注入脚本。模板占位符用{name}风格,别和前端框架的{{name}}搞混,否则渲染后剩一对花括号留在页面上,排查半天找不到原因。

机器人话术之外还有一个容易被漏掉的点:访客发送消息时的自动回复语,比如"已收到您的消息,正在为您排队"。这条消息也要走模板系统,但它的渲染结果不能只做一次——如果队列位置变了,更新提醒要用新的queue_position重新渲染,旧的已发出消息不动,避免刷屏。

5. 智优客服2.0 多商户客服系统排查:五个翻车记录与修复

下面这些坑不是理论推演,是我把这类多商户客服系统从开发环境带上生产时最常见的现场。每条按现象、原因、解决三个部分写,方便你直接对照排查。

5.1 会话被其它商户看见了

现象:某坐席接完一个会话后,工作台的会话列表里偶尔出现其它商户的会话,刷新后又不一定复现。 原因:消息推送用了Redis的pub/sub,订阅频道名只用了merchant_id,但Redis里存坐席连接时漏存了tenant_id,导致不同租户的推送落到同一个频道。 解决:连接注册时把tenant_id一起写进Redis hash,订阅频道改成tenant:{tenant_id}:merchant:{merchant_id},推送前再校验一遍归属。凡是涉及"按ID推送"的地方,推送对象必须同时持有租户和商户两层条件,这个校验不能省。

5.2 断网重连后消息重复

现象:访客网络波动重连后,坐席端出现两条一模一样的消息,甚至出现乱序。 原因:客户端断线后把整个发送队列重发,服务端没有做消息去重;重连后消息进入不同的处理线程,顺序也没保证。 解决:消息表加client_msg_id字段做唯一索引,服务端先insert ignoring再确认。前端为每条消息生成UUID,只有收到服务端确认才从发送队列移除,否则一直重发。消息排序上,同一个session_id的消息要保证进入同一个处理通道,多实例部署时按session_id做一致性hash路由,从根上消除乱序。

5.3 机器人自动回复语言跑偏

现象:西班牙语访客收到的欢迎语是英文,坐席界面显示访客语言是es,机器人回复死活是英文。 原因:运营在后台语言设置里把"界面语言"切成了西语,那个设置只影响管理界面,没有作用于访客会话。机器人回复时读的是全局默认语言,没读会话的visitor_lang字段。 解决:把语言设置拆成三个独立配置:后台界面语言、商户默认接待语言、访客可选语种。机器人回复只认chat_session.visitor_lang。上线前用不同语种的访客身份各跑一遍全流程,把"西语访客收到英文欢迎语"这类的用例写进回归测试。

5.4 机器人转人工时坐席不知道前面聊了什么

现象:机器人和访客已经聊了五轮"我要退款",转人工后坐席看到的是一个全新会话,首条消息是访客又发了一遍"你好"。 原因:机器人会话和人工会话用了两个session_id,消息归属到不同会话,坐席端只拉了人工会话的数据。 解决:会话ID全程不变,转人工只更新session_type和owner_agent_id。前端在点击转人工按钮时带上原session_id,后端做幂等合并。会话详情查询按session_id拉全部消息,再按message_type区分机器人和人工,展示时用时间线排好。这个改造要尽早做,因为存量会话分开后很难再合并。

5.5 接待时长出现负数

现象:客服报表里出现"接待时长-5分钟",坐席和运营都看傻了。 原因:数据库存的是不带时区的本地时间字符串,商户服务器在UTC+8,定时任务在UTC时间写数据,报表SQL直接做减法,时区没对齐。 解决:所有时间字段统一存UTC时间戳,数据库类型用带时区的timestamptz,展示层再转商户时区。统计报表在SQL层统一用AT TIME ZONE转换,不要把时区逻辑散落在各语言代码里。排这个坑时要把存量数据全量刷一遍,前端展示组件也要检查,不能有的地方转时区有的地方不转。

6. 上线前用一条消息做完链路验证:排查技巧与压测习惯

多商户多语言客服系统最怕的不是功能缺失,而是链路太长导致问题定位慢。我的习惯是给每一条消息分配唯一的message_id,从访客发送到坐席收到、从坐席回复到访客已读,每一跳都打印带message_id和tenant_id的结构化日志。上线前做一次"一条消息走全程"的演练:模拟访客发送消息,先看WebSocket握手日志,再看服务端接收日志,再看推送延迟,最后看坐席端渲染。哪个环节断了,grep message_id就能定位到具体日志行。

压测时别只压HTTP接口,在线客服的重点是WebSocket连接数和消息吞吐。我会模拟固定商户下同时在线几十个坐席和几百个访客,每个连接每5秒发一条消息,观察服务端推送延迟和Redis连接数。压测中只要出现消息乱序,先检查负载均衡配置——多实例部署时同一个session_id被分到了不同实例,这是最常见的乱序源头。

我还会准备一个"测试商户"专属的租户ID,所有生产演练都在这一个租户下做,杜绝测试会话混进真实商户数据。这是个很小的习惯,但能省掉很多数据清理的麻烦。接这类项目以来最大的教训是链路越靠后出问题越难查,所以我在任何客服系统上线前都坚持先把全链路日志打通,再放开商户接入,遇到可疑问题先查租户条件再查消息顺序。这个顺序帮我把排查时间从小时级压到分钟级,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询