指南
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 方案上線更快、且永遠全功能。