指南
In-app chat (WebView)
用 WebView 把完整客服挂件嵌入任意 App , 全功能、自动更新、一次接入。
在原生 App 里加客服,最快的方式是用 WebView 加载我们托管的全屏挂件页。你会得到与网页版 完全一致的挂件 , 首页、消息、帮助文档、搜索、表情、附件、时间戳、已读、结束会话 , 而且随平台自动更新。一次接入之后,App 无需再为客服功能改代码、发新版。这正是 Crisp、 Intercom 移动端的做法。
快速开始
让 WebView 加载这个地址。用户已登录时,必须带上 externalId 和 email(最好再加 name),否则客服后台永远只看到匿名访客。每个参数值都要 URL 编码(@→%40、空格→%20、中文名也要编码):
https://<你的域名>/api/widget-embed?appId=YOUR_APP_ID&externalId=USER_ID&email=USER_EMAIL&name=USER_NAME只有用户未登录时,才退回匿名形式:
https://<你的域名>/api/widget-embed?appId=YOUR_APP_ID⚠️ 后台显示「匿名访客」、没有邮箱?这是最常见的接入错误:你的 WebView 地址里 漏了 email/externalId。系统不可能凭空知道用户是谁,必须 App 主动传。 若访客 ID 长得像 anon-…,就说明什么都没传(那是挂件自动生成的匿名号)。
JavaScript / 任意 WebView(非 Flutter)
Electron、桌面壳、React Native、或你自己设 URL 的原生 WKWebView , 用 JS 拼地址即可。URLSearchParams 会自动帮你做 URL 编码,不用手动转义:
// 打开客服 WebView 前,用当前登录用户拼出地址。
function buildSupportUrl(domain, user) {
const params = new URLSearchParams({
appId: 'YOUR_APP_ID',
platform: 'windows', // 强烈建议:决定这个访客能看到哪些帮助文档
// ios / macos_appstore / macos / android / windows / web
// locale: userSelectedLanguage, // 可选:App 有语言开关时才传,并加 forceLocale: '1'
// 不传 = 跟随设备语言(覆盖 App Store 的 50 种本地化)
// —— 登录用户务必传这几项,否则后台是「匿名访客」——
externalId: user.id, // 你系统里的用户唯一 ID
email: user.email, // 用户邮箱(关键)
name: user.name || '', // 用户名(可选)
// hmac: user.hmac, // 可选:由你服务端算,防止冒充(见下方 HMAC)
// attrs: JSON.stringify({ 套餐: user.plan, 到期日: user.expireAt }), // 可选:自定义资料
});
return `https://${domain}/api/widget-embed?${params.toString()}`;
}
// 用法:把返回的 url 交给 WebView 加载(替换掉原来只有 ?appId=... 的地址)。
const url = buildSupportUrl('singchatweb.com', currentUser);
myWebView.loadURL(url); // Electron: win.loadURL(url);原生:注入该 URL接入步骤(App 开发者照做)
- 在
pubspec.yaml加 3 个依赖:webview_flutter、http、shared_preferences。 - 把下方「复制即用的完整文件」里的
support_service.dart整段复制,放进lib/(5 个抗封锁域名已填好)。 - 在「联系客服」按钮里调用它,并务必带上当前登录的用户,否则客服后台看到的是匿名访客(没有姓名/邮箱):
// 在「联系客服」按钮里,务必带上当前登录的用户 + 尽量多的资料(越详细,客服越好处理)。 // 下面这些字段名换成你们 user 对象里的真实字段;用不到的删掉即可。 await openSupport(context, user: SupportUser( externalId: currentUser.id, // VPN 用户唯一 ID email: currentUser.email, name: currentUser.nickname, hmac: hmacFromYourServer, // 强烈建议:由你服务端算,防冒充 appVersion: appVersion, // App 版本(package_info_plus 取,或写死) deviceModel: deviceModel, // 设备型号(device_info_plus 取) attributes: { '套餐': currentUser.plan, // 会员/套餐 '会员状态': currentUser.isActive ? '有效' : '已过期', '到期日': currentUser.expireDate, '剩余流量': currentUser.dataLeft, '是否试用': currentUser.isTrial ? '是' : '否', '注册时间': currentUser.registeredAt, '支付方式': currentUser.payMethod, '当前节点': currentUser.currentNode, // VPN 排障常用 }, )); - 重新构建、发一次版即可。
之前已经接了旧版?把文件替换成下方最新版,并给你现有的 openSupport 调用加上 user: 参数即可。
Flutter(webview_flutter)
在 pubspec.yaml 加 webview_flutter: ^4.x,然后:
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
class SupportPage extends StatefulWidget {
const SupportPage({super.key});
@override
State<SupportPage> createState() => _SupportPageState();
}
class _SupportPageState extends State<SupportPage> {
late final WebViewController _controller;
@override
void initState() {
super.initState();
// 登录用户务必带上 externalId + email,否则后台是「匿名访客」(没有姓名/邮箱)。
// 未登录时才只传 appId + locale。Uri.https 会自动做 URL 编码。
final uri = Uri.https('singchatweb.com', '/api/widget-embed', {
'appId': 'YOUR_APP_ID',
'platform': Platform.isIOS ? 'ios' : 'android', // 强烈建议:决定可见的帮助文档
// 不传 locale = 跟随设备语言(已覆盖 App Store 的 50 种本地化)。
// App 自己有语言开关时才传,并同时加 'forceLocale': '1':
// 'locale': appSettings.selectedLanguage,
'externalId': user.id, // 你系统里的用户唯一 ID —— 关键
'email': user.email, // 用户邮箱 —— 关键
'name': user.name, // 可选
// 'hmac': hmacFromYourServer, // 可选:由你服务端算,防冒充
});
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.white)
..loadRequest(uri);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Support')),
// Full-screen: drop the appBar and use your own close button.
body: SafeArea(child: WebViewWidget(controller: _controller)),
);
}
}
// Open it from wherever your "Support" button lives:
// Navigator.push(context,
// MaterialPageRoute(builder: (_) => const SupportPage()));其它技术栈的 WebView(Swift WKWebView、Kotlin WebView、React Native react-native-webview)做法完全一样:加载地址、开启 JavaScript 即可。
原生 iOS 与 Android
没有 SDK 要装 , 同样只是拼出这个地址交给 WebView。注意 Mac:上架版与 DMG 版 是同一个操作系统,所以那个值必须来自编译期 flag,不能靠运行期判断。
// ── iOS (Swift / WKWebView) ────────────────────────────────
// Mac Catalyst / macOS 版把 platform 换成 macos_appstore 或 macos。
#if targetEnvironment(macCatalyst)
let platform = "macos_appstore" // 上架版;官网 DMG 版传 "macos"
#else
let platform = "ios" // iPhone 与 iPad 同属 ios
#endif
var comps = URLComponents(string: "https://\(domain)/api/widget-embed")!
comps.queryItems = [
.init(name: "appId", value: "YOUR_APP_ID"),
.init(name: "platform", value: platform),
.init(name: "externalId", value: user.id), // 登录用户务必传
.init(name: "email", value: user.email), // 登录用户务必传
.init(name: "name", value: user.name),
// App 自己有语言开关时才传这两项:
// .init(name: "locale", value: settings.language),
// .init(name: "forceLocale", value: "1"),
]
webView.load(URLRequest(url: comps.url!)) // URLComponents 自动做 URL 编码
// ── Android (Kotlin / WebView) ─────────────────────────────
val url = Uri.parse("https://$domain/api/widget-embed")
.buildUpon()
.appendQueryParameter("appId", "YOUR_APP_ID")
.appendQueryParameter("platform", "android")
.appendQueryParameter("externalId", user.id)
.appendQueryParameter("email", user.email)
.appendQueryParameter("name", user.name)
// .appendQueryParameter("locale", settings.language)
// .appendQueryParameter("forceLocale", "1")
.build()
webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true // 挂件用 localStorage 记住访客
webView.loadUrl(url.toString())URL 参数
appId(必填) , 你收件箱的 App ID。locale, 界面语言。绝大多数情况不要传 , 不传时挂件跟随 WebView 上报的设备语言,这也是用户的预期。界面文案已覆盖 App Store Connect 的全部 50 种本地化,清单之外的语言回退英文。 只有当你的 App 自己有语言开关时才传 , 传用户选的那个,并同时带上forceLocale=1让它盖过设备语言。绝对不要写死:写死zh-Hans正是日语 App 里出现中文挂件的原因。platform(强烈建议传) , 访客用的是哪个构建。 它决定这个访客能看到哪些帮助文档,以及 AI 回复受不受 App Store 约束。 不传则一律当成web,等于没有任何过滤。取值:ios(iPhone与 iPad)、macos_appstore(Mac App Store 上架版)、macos(官网 DMG 直装版)、android、windows、web。externalId, 你系统里的用户唯一 ID。传了客服后台就能认出用户、合并其历史会话。email、name、avatarUrl, 展示给客服的资料。hmac, 身份签名(见下)。可选。
按平台隐藏内容(App Store 合规)
App Store 3.1.1 不允许 App 内展示站外购买、订阅与返佣内容。因此每一篇帮助文档、 每一条 FAQ 都可以单独设置在哪些平台隐藏 , 在后台的「帮助中心」或「FAQ」页面, 点亮那一条下面的平台标签即可。被隐藏后,该平台的访客在列表里看不到它、 用直链也打不开,AI 检索同样会排除它,所以内容根本不会出现在回答里。
能不能生效取决于两件事。第一,App 必须传 platform , 不传的话过滤器 没有依据。第二,传的值要反映构建类型,不能只看操作系统:
- iPad 不单列。iPad 跑的是同一个 iOS App、受同一套规则约束, 所以
ios已经涵盖 iPhone 与 iPad。 - Mac 有两个构建。Mac App Store 上架版受 3.1.1 约束, 官网直接下载的 DMG 版不受。两者是同一个操作系统,运行期分辨不出来 , 要在构建时决定(例如用 Xcode 的编译期 flag),分别传
macos_appstore与macos。传错的后果:上架版会在审核时 露出订阅内容,或者 DMG 版白白少给用户一半的说明文档。
改动即时生效 , 这个设置存在后台、不在 App 里,所以调整可见范围永远不需要发版。
身份签名(HMAC,可选)
为证明某个 externalId 确实是你们已登录的用户(防止冒充),可附带 HMAC 签名。签名必须在你们的服务端计算 , 密钥绝不能进 App。
- 算法:
hmac = HMAC-SHA256(secretKey, externalId),输出 64 位小写十六进制。 - payload 就是
externalId(没有 id 时用email)。 secretKey是你收件箱的密钥(一个收件箱一个,在收件箱设置里取)。不传hmac则该访客视为「未验证身份」。
import { createHmac } from 'node:crypto';
// SECRET_KEY lives only on your server, never ship it inside the app.
const hmac = createHmac('sha256', SECRET_KEY)
.update(externalId) // exactly the externalId you pass to the widget
.digest('hex');
// Return { externalId, email, hmac } to the app, which appends them to the URL.平台配置
- Android:保留
INTERNET权限;拦截物理返回键,先让 WebView 返回上一屏。 新版webview_flutter支持网页里的<input type="file">发附件。 - iOS:走 HTTPS,无需 ATS 例外。图片上传是挂件自带功能,
WKWebView会自动弹出系统相册选择器,原生端不用写任何代码。从相册选图无需任何权限;只有想支持「拍照」时,才在Info.plist加一行NSCameraUsageDescription(相机用途说明),否则用户点拍照会闪退。
在被墙网络下保证可达
如果用户的网络屏蔽了客服域名,WebView 就打不开,要强调这是域名可达性问题,不是 WebView 的问题:原生 SDK 连的是同一批域名,一样打不开。用 VPN App 处理自己入口服务器的那套办法来处理它。
- 内置一份种子域名清单。打开时对每个域名探测
GET /api/ping,用第一个返回 200 的。 - 绝不只记住一个域名,每次启动都重新探测「种子 ∪ 上次持久化清单」的并集,拉清单时带防缓存参数
?t=…,这样某个域名被墙也不会一直卡在它上面。 - 全被墙?弹原生兜底(邮件 / Telegram / 官网)+「先连上 VPN 再重试」提示,绝不白屏。
- 只要有一个域名能通,就从
GET /api/support-endpoints刷新最新完整清单,这样轮换域名无需重新发版。 - 实时通道跟随加载挂件的那个域名,所以一个能通的镜像域名会把整套(页面、接口、WebSocket)都带上。
- 原生层用 DoH 或内置 IP 解析,规避 DNS 污染(你们对 VPN 已经在做)。VPN 连上时,挂件直接走隧道加载。
import 'dart:convert';
import 'package:http/http.dart' as http;
// Seed list is baked into the app. Keep several DIFFERENT domains and keep at
// least one alive long-term, this is your bootstrap, like a VPN's entry servers.
const seedDomains = ['singchat.org', 'support-alt-1.com', 'support-alt-2.com'];
// ALWAYS re-probe on launch, never trust a single "remembered" domain, or a
// blocked one will stick forever. Probe the union of seed + last-saved pool.
Future<String?> pickReachableDomain(List<String> domains) async {
for (final d in domains) {
try {
final r = await http.get(Uri.https(d, '/api/ping'))
.timeout(const Duration(seconds: 4));
if (r.statusCode == 200) return d; // first that answers wins
} catch (_) {/* blocked / unreachable, try the next */}
}
return null; // everything blocked
}
Future<void> openSupportResilient(List<String> savedPool) async {
final domains = {...seedDomains, ...savedPool}.toList(); // union, de-duped
final domain = await pickReachableDomain(domains);
if (domain == null) {
showNativeFallback(); // email / Telegram / your site + "connect the VPN, then retry"
return;
}
openSupport(Uri.https(domain, '/api/widget-embed', {'appId': 'YOUR_APP_ID'}));
// Refresh the pool from the REACHABLE domain, with a cache-buster so you never
// get a stale list. Persist it for next launch (merged with the seed list).
try {
final r = await http.get(Uri.https(domain, '/api/support-endpoints',
{'t': DateTime.now().millisecondsSinceEpoch.toString()}));
final list = (jsonDecode(r.body)['endpoints'] as List).cast<String>();
await persistPool(list);
} catch (_) {}
}纯 JavaScript 版(Electron / 桌面 / RN,非 Flutter)。注意:身份参数 (externalId/email/hmac)在每个镜像域名下都完全一样 , HMAC 签的是 externalId、不是域名, 所以轮换域名永远不会让身份失效,把同一套参数拼到哪个能通的域名上即可:
// 种子域名(顺序=优先探测顺序;保留几个不同域名,至少一个长期可达)。
const SEED_DOMAINS = ['singchatly.com', 'singchatweb.com', 'singchatapp.com', 'singchatapi.com', 'singchat.org'];
// 逐个探 /api/ping,用第一个能通的。每次都重探,绝不记死一个被墙的。
async function pickReachableDomain(domains) {
for (const d of domains) {
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 4000);
const r = await fetch(`https://${d}/api/ping`, { signal: ctrl.signal });
clearTimeout(t);
if (r.ok) return d; // 第一个应答的即用
} catch { /* 连不上 → 试下一个 */ }
}
return null; // 全部连不上
}
// 入口:在「联系客服」按钮里调用。
async function openSupport(user) {
const saved = JSON.parse(localStorage.getItem('singchat_pool') || '[]');
const domains = [...new Set([...SEED_DOMAINS, ...saved])]; // 种子 ∪ 上次持久化,去重
const domain = await pickReachableDomain(domains);
if (!domain) { showNativeFallback(); return; } // 全被墙 → 兜底(邮箱/Telegram)
// 关键:身份参数(externalId/email/name/hmac)在任何镜像域名下都一样,直接拼上。
myWebView.loadURL(buildSupportUrl(domain, user)); // buildSupportUrl 见上一段
// 后台从可达域名刷新完整域名池(带 ?t= 防缓存),持久化供下次探测。
try {
const r = await fetch(`https://${domain}/api/support-endpoints?t=${Date.now()}`);
const hosts = (await r.json()).endpoints.map((u) => new URL(u).host);
localStorage.setItem('singchat_pool', JSON.stringify(hosts));
} catch {}
}复制即用的完整文件(含 5 个域名)
想直接丢一个文件搞定?这就是全部,域名探测、被墙自动切换、原生兜底、全屏 WebView,5 个域名已填好。 加 3 个依赖、把文件放进 lib/、在「联系客服」按钮里调 openSupport(context) 即可。
// support_service.dart,SingChat 客服接入(含抗封锁多域名探测 + 兜底)。复制即用。
//
// 1) pubspec.yaml 加依赖:
// webview_flutter: ^4.7.0
// http: ^1.2.0
// shared_preferences: ^2.2.0
// 2) 把本文件放进 lib/ 下。
// 3) 在你的「联系客服」按钮点击回调里调用: await openSupport(context);
import 'dart:convert';
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:webview_flutter/webview_flutter.dart';
// 你的收件箱 App ID
const _appId = 'sl_web_bc11b2c3';
// 种子域名清单(顺序=优先探测顺序;保留几个不同域名,至少一个长期可达)
const _seedDomains = [
'singchatly.com',
'singchatweb.com',
'singchatapp.com',
'singchatapi.com',
'singchat.org',
];
/// 登录用户资料 , 传给客服后台,让访客显示姓名/邮箱/套餐/设备,而不是「匿名访客」。
/// 越详细越好:attributes 里想记什么就传什么(套餐、到期日、剩余流量、是否试用…)。
class SupportUser {
const SupportUser({
this.externalId,
this.email,
this.name,
this.hmac,
this.appVersion,
this.deviceModel,
this.attributes,
});
final String? externalId; // 你系统里的用户唯一 ID
final String? email;
final String? name;
final String? hmac; // 由你服务端算:HMAC-SHA256(收件箱密钥, externalId),防冒充
final String? appVersion; // App 版本(后台「App版本」列)
final String? deviceModel; // 设备型号(后台「设备型号」列)
final Map<String, String>? attributes; // 自定义资料,如 {'套餐':'Rich','到期日':'2026-12-01'}
}
/// 入口:在「联系客服」按钮里调用。自动挑一个能连通的域名打开客服;全被墙则弹兜底。
///
/// ⚠️ 已登录用户务必传 user,否则客服后台看到的是匿名访客(没有姓名/邮箱)。越详细越好:
/// await openSupport(context, user: SupportUser(
/// externalId: currentUser.id, email: currentUser.email, name: currentUser.nickname,
/// hmac: hmacFromYourServer, // 强烈建议,防止冒充他人
/// appVersion: appVersion, deviceModel: deviceModel,
/// attributes: {
/// '套餐': currentUser.plan, '会员状态': currentUser.isActive ? '有效' : '已过期',
/// '到期日': currentUser.expireDate, '剩余流量': currentUser.dataLeft,
/// '是否试用': currentUser.isTrial ? '是' : '否', '当前节点': currentUser.currentNode,
/// },
/// ));
/// locale 留空 = 跟随设备语言(已覆盖 App Store 的 50 种本地化,其余回退英文)。
Future<void> openSupport(BuildContext context,
{String? locale, SupportUser? user}) async {
final domain = await _pickReachableDomain();
if (domain == null) {
_showFallback(context); // 全部被墙 → 原生兜底
return;
}
if (context.mounted) {
Navigator.push(context, MaterialPageRoute(
builder: (_) => _SupportPage(domain: domain, locale: locale, user: user)));
}
_refreshPool(domain); // 后台刷新域名池,供下次探测(免发版轮换)
}
/// 合并「种子 ∪ 上次持久化清单」,逐个探 /api/ping,用第一个能通的。每次都重探,绝不记死一个。
Future<String?> _pickReachableDomain() async {
final saved = await _loadPool();
final domains = <String>{..._seedDomains, ...saved}.toList();
for (final d in domains) {
try {
final r = await http.get(Uri.https(d, '/api/ping'))
.timeout(const Duration(seconds: 4));
if (r.statusCode == 200) return d; // 第一个应答的即用
} catch (_) {/* 连不上(被墙/超时)→ 试下一个 */}
}
return null; // 全部连不上
}
/// 从可达域名刷新完整域名池(带 ?t= 防缓存),持久化供下次启动。
Future<void> _refreshPool(String domain) async {
try {
final t = DateTime.now().millisecondsSinceEpoch.toString();
final r = await http.get(Uri.https(domain, '/api/support-endpoints', {'t': t}))
.timeout(const Duration(seconds: 6));
final hosts = (jsonDecode(r.body)['endpoints'] as List)
.cast<String>().map((u) => Uri.parse(u).host).toList();
await _savePool(hosts);
} catch (_) {}
}
Future<List<String>> _loadPool() async {
final p = await SharedPreferences.getInstance();
return p.getStringList('singchat_pool') ?? const [];
}
Future<void> _savePool(List<String> hosts) async {
final p = await SharedPreferences.getInstance();
await p.setStringList('singchat_pool', hosts);
}
/// 全被墙时的兜底(把邮箱 / Telegram 换成你们自己的)。
void _showFallback(BuildContext context) {
showDialog(context: context, builder: (_) => AlertDialog(
title: const Text('暂时连不上在线客服'),
content: const Text('''请先连接 VPN 后重试,或通过以下方式联系我们:
邮箱:support@singlinkvpn.com
Telegram:@your_support'''),
actions: [TextButton(
onPressed: () => Navigator.pop(context), child: const Text('知道了'))],
));
}
/// 全屏 WebView 客服页 , 加载即拥有网页版全部功能。
/// ⚠️ Mac 必须区分上架版与 DMG 直装版:前者受 App Store 3.1.1 约束、后者不受,
/// 而两者是同一个系统、运行期分辨不出来。用编译期常量决定:
/// flutter build macos --dart-define=MAC_APP_STORE=true
const _isMacAppStore = bool.fromEnvironment('MAC_APP_STORE');
String _platformName() {
if (Platform.isIOS) return 'ios'; // iPhone 与 iPad 同属 ios
if (Platform.isAndroid) return 'android';
if (Platform.isMacOS) return _isMacAppStore ? 'macos_appstore' : 'macos';
if (Platform.isWindows) return 'windows';
return 'web';
}
class _SupportPage extends StatefulWidget {
const _SupportPage({required this.domain, this.locale, this.user});
final String domain;
final String? locale;
final SupportUser? user;
@override
State<_SupportPage> createState() => _SupportPageState();
}
class _SupportPageState extends State<_SupportPage> {
late final WebViewController _c;
@override
void initState() {
super.initState();
final u = widget.user;
final params = <String, String>{
'appId': _appId,
if (widget.locale != null) 'locale': widget.locale!,
'platform': _platformName(), // 决定可见的帮助文档 + AI 是否受 App Store 约束
if (u?.externalId != null) 'externalId': u!.externalId!,
if (u?.email != null) 'email': u!.email!,
if (u?.name != null) 'name': u!.name!,
if (u?.hmac != null) 'hmac': u!.hmac!,
if (u?.appVersion != null) 'appVersion': u!.appVersion!,
if (u?.deviceModel != null) 'deviceModel': u!.deviceModel!,
if (u?.attributes != null && u!.attributes!.isNotEmpty)
'attrs': jsonEncode(u.attributes),
};
final uri = Uri.https(widget.domain, '/api/widget-embed', params);
_c = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.white)
..loadRequest(uri);
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('在线客服')),
body: SafeArea(child: WebViewWidget(controller: _c)),
);
}想要纯原生 UI?
如果你需要自己手写原生聊天界面(而非 WebView),用 Mobile SDKs 页里那套「仅逻辑」客户端。 对大多数 App 来说,上面的 WebView 方案上线更快、且永远全功能。