简介智优客服 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_idmerchant_idvisitor_idstatusopen已存在未结束会话就直接返回原会话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_idtenant_id, merchant_idmerchant_id, visitor_idvisitor_id, visitor_langvisitor_lang, # 会话创建时写死后续不随界面切换变化 statusrobot, created_atnow_utc() ) db.add(session) db.commit() return session参数说明visitor_lang在创建时写死是因为访客可能中途切换界面语言但接待语言应当以会话创建时为准否则坐席聊到一半发现语言变了上下文直接断裂。statusrobot表示新会话优先由机器人接待也可以根据商户开关默认置为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(code4401) 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, interval25)关键参数心跳间隔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(keylambda 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_keykey注册表、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显式参数大于CookieCookie大于浏览器Accept-Language最后才是商户默认语言。这个顺序不能乱因为三种来源的控制权归属不同。URL参数是营销投放时由活动链接带来的Cookie是访客自己上次的选择浏览器头是系统推测商户默认只是兜底。def detect_visitor_lang(request, merchant_allowed_langs): # 显式参数是营销投放最常见的来源比如活动页 ?langes 打开 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_langen): # 先查商户覆盖再查平台基础包 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, keytpl_key, langlang) # 简单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分钟坐席和运营都看傻了。 原因数据库存的是不带时区的本地时间字符串商户服务器在UTC8定时任务在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所有生产演练都在这一个租户下做杜绝测试会话混进真实商户数据。这是个很小的习惯但能省掉很多数据清理的麻烦。接这类项目以来最大的教训是链路越靠后出问题越难查所以我在任何客服系统上线前都坚持先把全链路日志打通再放开商户接入遇到可疑问题先查租户条件再查消息顺序。这个顺序帮我把排查时间从小时级压到分钟级希望帮到你。本文还有配套的精品资源点击获取