feat(dingtalk): OAuth one-tap binding + voice tool + public Kong route
- DingTalk binding UX replaced with OAuth one-tap flow:
- GET /api/v1/agent/channels/dingtalk/oauth/init returns OAuth URL
- GET /api/v1/agent/channels/dingtalk/oauth/callback (public, no JWT)
exchanges code+state for openId, saves binding, returns HTML page
- oauthStates Map with 10-min TTL; state validated before exchange
- msg.senderId (openId) aligned with OAuth openId for consistent routing
- CODE_TTL_MS extended from 5→15 min (fallback code method preserved)
- Kong: dingtalk-oauth-public service declared before agent-service
so callback path matches without JWT plugin
- Voice sessions: use stored session.systemPrompt + voice rules;
allowedTools includes Bash so Claude can call internal APIs
- Flutter _DingTalkBindSheet: OAuth-first UX with code-based fallback
phases: idle→loadingOAuth→waitingOAuth→success + polling every 2s
- docker-compose: IT0_BASE_URL env var for agent-service (redirect URI)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2d0bdbd27f
commit
3d626aebb5
|
|
@ -152,6 +152,7 @@ services:
|
||||||
- AGENT_SERVICE_PUBLIC_URL=${AGENT_SERVICE_PUBLIC_URL}
|
- AGENT_SERVICE_PUBLIC_URL=${AGENT_SERVICE_PUBLIC_URL}
|
||||||
- IT0_DINGTALK_CLIENT_ID=${IT0_DINGTALK_CLIENT_ID:-}
|
- IT0_DINGTALK_CLIENT_ID=${IT0_DINGTALK_CLIENT_ID:-}
|
||||||
- IT0_DINGTALK_CLIENT_SECRET=${IT0_DINGTALK_CLIENT_SECRET:-}
|
- IT0_DINGTALK_CLIENT_SECRET=${IT0_DINGTALK_CLIENT_SECRET:-}
|
||||||
|
- IT0_BASE_URL=${IT0_BASE_URL:-https://it0api.szaiai.com}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3002/',r=>{process.exit(r.statusCode<500?0:1)}).on('error',()=>process.exit(1))\""]
|
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3002/',r=>{process.exit(r.statusCode<500?0:1)}).on('error',()=>process.exit(1))\""]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|
|
||||||
|
|
@ -718,9 +718,11 @@ class _TemplateChip extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// DingTalk bind bottom sheet
|
// DingTalk bind bottom sheet — OAuth-first (one-tap Authorize)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
enum _BindPhase { idle, loadingOAuth, waitingOAuth, loadingCode, waitingCode, success, expired, error }
|
||||||
|
|
||||||
class _DingTalkBindSheet extends StatefulWidget {
|
class _DingTalkBindSheet extends StatefulWidget {
|
||||||
final AgentInstance instance;
|
final AgentInstance instance;
|
||||||
final VoidCallback? onBound;
|
final VoidCallback? onBound;
|
||||||
|
|
@ -732,22 +734,16 @@ class _DingTalkBindSheet extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DingTalkBindSheetState extends State<_DingTalkBindSheet> {
|
class _DingTalkBindSheetState extends State<_DingTalkBindSheet> {
|
||||||
static const _dtScheme = 'dingtalk://';
|
_BindPhase _phase = _BindPhase.idle;
|
||||||
|
|
||||||
_BindPhase _phase = _BindPhase.loading;
|
|
||||||
String _code = '';
|
|
||||||
DateTime? _expiresAt;
|
|
||||||
String? _errorMsg;
|
String? _errorMsg;
|
||||||
Timer? _pollTimer;
|
Timer? _pollTimer;
|
||||||
|
|
||||||
|
// Code fallback state
|
||||||
|
String _code = '';
|
||||||
|
DateTime? _expiresAt;
|
||||||
Timer? _countdownTimer;
|
Timer? _countdownTimer;
|
||||||
int _secondsLeft = 0;
|
int _secondsLeft = 0;
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_fetchCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_pollTimer?.cancel();
|
_pollTimer?.cancel();
|
||||||
|
|
@ -755,31 +751,47 @@ class _DingTalkBindSheetState extends State<_DingTalkBindSheet> {
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _fetchCode() async {
|
// ── OAuth flow ─────────────────────────────────────────────────────────────
|
||||||
setState(() { _phase = _BindPhase.loading; _errorMsg = null; });
|
|
||||||
|
Future<void> _startOAuth() async {
|
||||||
|
setState(() { _phase = _BindPhase.loadingOAuth; _errorMsg = null; });
|
||||||
try {
|
try {
|
||||||
final container = ProviderScope.containerOf(context);
|
final dio = ProviderScope.containerOf(context).read(dioClientProvider);
|
||||||
final dio = container.read(dioClientProvider);
|
final res = await dio.get(
|
||||||
|
'${ApiEndpoints.agent}/channels/dingtalk/oauth/init',
|
||||||
|
queryParameters: {'instanceId': widget.instance.id},
|
||||||
|
);
|
||||||
|
final oauthUrl = (res.data as Map<String, dynamic>)['oauthUrl'] as String;
|
||||||
|
final uri = Uri.parse(oauthUrl);
|
||||||
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
|
setState(() { _phase = _BindPhase.waitingOAuth; });
|
||||||
|
_startPolling();
|
||||||
|
} catch (e) {
|
||||||
|
setState(() { _phase = _BindPhase.error; _errorMsg = ErrorHandler.friendlyMessage(e); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Code fallback ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> _fetchCode() async {
|
||||||
|
setState(() { _phase = _BindPhase.loadingCode; _errorMsg = null; });
|
||||||
|
try {
|
||||||
|
final dio = ProviderScope.containerOf(context).read(dioClientProvider);
|
||||||
final res = await dio.post(
|
final res = await dio.post(
|
||||||
'${ApiEndpoints.agent}/channels/dingtalk/bind/${widget.instance.id}',
|
'${ApiEndpoints.agent}/channels/dingtalk/bind/${widget.instance.id}',
|
||||||
);
|
);
|
||||||
final data = res.data as Map<String, dynamic>;
|
final data = res.data as Map<String, dynamic>;
|
||||||
final code = data['code'] as String;
|
final expiresAt = DateTime.fromMillisecondsSinceEpoch(data['expiresAt'] as int);
|
||||||
final expiresAtMs = data['expiresAt'] as int;
|
|
||||||
final expiresAt = DateTime.fromMillisecondsSinceEpoch(expiresAtMs);
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_code = code;
|
_code = data['code'] as String;
|
||||||
_expiresAt = expiresAt;
|
_expiresAt = expiresAt;
|
||||||
_secondsLeft = expiresAt.difference(DateTime.now()).inSeconds.clamp(0, 600);
|
_secondsLeft = expiresAt.difference(DateTime.now()).inSeconds.clamp(0, 900);
|
||||||
_phase = _BindPhase.waitingCode;
|
_phase = _BindPhase.waitingCode;
|
||||||
});
|
});
|
||||||
_startCountdown();
|
_startCountdown();
|
||||||
_startPolling();
|
_startPolling();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() {
|
setState(() { _phase = _BindPhase.error; _errorMsg = ErrorHandler.friendlyMessage(e); });
|
||||||
_phase = _BindPhase.error;
|
|
||||||
_errorMsg = ErrorHandler.friendlyMessage(e);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -790,21 +802,22 @@ class _DingTalkBindSheetState extends State<_DingTalkBindSheet> {
|
||||||
final left = _expiresAt!.difference(DateTime.now()).inSeconds;
|
final left = _expiresAt!.difference(DateTime.now()).inSeconds;
|
||||||
if (left <= 0) {
|
if (left <= 0) {
|
||||||
_countdownTimer?.cancel();
|
_countdownTimer?.cancel();
|
||||||
setState(() { _phase = _BindPhase.expired; });
|
|
||||||
_pollTimer?.cancel();
|
_pollTimer?.cancel();
|
||||||
|
setState(() { _phase = _BindPhase.expired; });
|
||||||
} else {
|
} else {
|
||||||
setState(() { _secondsLeft = left; });
|
setState(() { _secondsLeft = left; });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Shared polling ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
void _startPolling() {
|
void _startPolling() {
|
||||||
_pollTimer?.cancel();
|
_pollTimer?.cancel();
|
||||||
_pollTimer = Timer.periodic(const Duration(seconds: 2), (_) async {
|
_pollTimer = Timer.periodic(const Duration(seconds: 2), (_) async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
try {
|
try {
|
||||||
final container = ProviderScope.containerOf(context);
|
final dio = ProviderScope.containerOf(context).read(dioClientProvider);
|
||||||
final dio = container.read(dioClientProvider);
|
|
||||||
final res = await dio.get(
|
final res = await dio.get(
|
||||||
'${ApiEndpoints.agent}/channels/dingtalk/status/${widget.instance.id}',
|
'${ApiEndpoints.agent}/channels/dingtalk/status/${widget.instance.id}',
|
||||||
);
|
);
|
||||||
|
|
@ -815,24 +828,11 @@ class _DingTalkBindSheetState extends State<_DingTalkBindSheet> {
|
||||||
setState(() { _phase = _BindPhase.success; });
|
setState(() { _phase = _BindPhase.success; });
|
||||||
widget.onBound?.call();
|
widget.onBound?.call();
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) { /* network hiccup — keep polling */ }
|
||||||
// network hiccup — keep polling
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openDingTalk() async {
|
// ── Build ──────────────────────────────────────────────────────────────────
|
||||||
final uri = Uri.parse(_dtScheme);
|
|
||||||
if (await canLaunchUrl(uri)) {
|
|
||||||
await launchUrl(uri);
|
|
||||||
} else {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('未检测到钉钉,请先安装钉钉')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -845,7 +845,6 @@ class _DingTalkBindSheetState extends State<_DingTalkBindSheet> {
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Drag handle
|
|
||||||
Container(
|
Container(
|
||||||
width: 36, height: 4,
|
width: 36, height: 4,
|
||||||
margin: const EdgeInsets.only(bottom: 20),
|
margin: const EdgeInsets.only(bottom: 20),
|
||||||
|
|
@ -854,26 +853,16 @@ class _DingTalkBindSheetState extends State<_DingTalkBindSheet> {
|
||||||
borderRadius: BorderRadius.circular(2),
|
borderRadius: BorderRadius.circular(2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Title
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.chat_bubble_outline, color: Color(0xFF1A73E8), size: 22),
|
const Icon(Icons.chat_bubble_outline, color: Color(0xFF1A73E8), size: 22),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Text(
|
const Text('绑定钉钉', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.textPrimary)),
|
||||||
'绑定钉钉',
|
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.textPrimary),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Text(
|
Text('「${widget.instance.name}」', style: const TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||||||
'「${widget.instance.name}」',
|
|
||||||
style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Content by phase
|
|
||||||
_buildPhaseContent(),
|
_buildPhaseContent(),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
|
|
@ -881,35 +870,129 @@ class _DingTalkBindSheetState extends State<_DingTalkBindSheet> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPhaseContent() {
|
Widget _buildPhaseContent() => switch (_phase) {
|
||||||
return switch (_phase) {
|
_BindPhase.idle => _buildIdle(),
|
||||||
_BindPhase.loading => const Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator()),
|
_BindPhase.loadingOAuth => _buildSpinner('正在获取授权地址…'),
|
||||||
_BindPhase.error => _buildError(),
|
_BindPhase.waitingOAuth => _buildWaitingOAuth(),
|
||||||
_BindPhase.expired => _buildExpired(),
|
_BindPhase.loadingCode => _buildSpinner('正在获取验证码…'),
|
||||||
_BindPhase.success => _buildSuccess(),
|
|
||||||
_BindPhase.waitingCode => _buildWaitingCode(),
|
_BindPhase.waitingCode => _buildWaitingCode(),
|
||||||
|
_BindPhase.success => _buildSuccess(),
|
||||||
|
_BindPhase.expired => _buildExpired(),
|
||||||
|
_BindPhase.error => _buildError(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Idle: show primary OAuth button ───────────────────────────────────────
|
||||||
|
|
||||||
|
Widget _buildIdle() {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
// Hero icon
|
||||||
|
Container(
|
||||||
|
width: 72, height: 72,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF1A73E8).withOpacity(0.12),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.chat_bubble_outline, color: Color(0xFF1A73E8), size: 36),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'一键连接钉钉',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.textPrimary),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'点击下方按钮,钉钉自动打开并请求授权\n点一下"同意"即完成绑定',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 13, color: AppColors.textMuted, height: 1.6),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
|
||||||
|
// Primary OAuth button
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
icon: const Icon(Icons.open_in_new, size: 18),
|
||||||
|
label: const Text('用钉钉授权绑定', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||||
|
onPressed: _startOAuth,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF1A73E8),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Fallback link
|
||||||
|
TextButton(
|
||||||
|
onPressed: _fetchCode,
|
||||||
|
child: const Text(
|
||||||
|
'没有自动打开?使用验证码手动绑定',
|
||||||
|
style: TextStyle(fontSize: 12, color: AppColors.textMuted),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Waiting OAuth: spinner + "已授权?" retry ─────────────────────────────
|
||||||
|
|
||||||
|
Widget _buildWaitingOAuth() {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const SizedBox(width: 48, height: 48, child: CircularProgressIndicator(strokeWidth: 3)),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
const Text(
|
||||||
|
'钉钉已打开',
|
||||||
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'请在钉钉中点击"同意授权"\n授权完成后此页面会自动跳转',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 13, color: AppColors.textMuted, height: 1.6),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: _startOAuth,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: const Color(0xFF1A73E8),
|
||||||
|
side: const BorderSide(color: Color(0xFF1A73E8)),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
|
),
|
||||||
|
child: const Text('重新打开钉钉'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _fetchCode,
|
||||||
|
child: const Text('切换到验证码方式', style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Code fallback ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Widget _buildWaitingCode() {
|
Widget _buildWaitingCode() {
|
||||||
final mm = (_secondsLeft ~/ 60).toString().padLeft(2, '0');
|
final mm = (_secondsLeft ~/ 60).toString().padLeft(2, '0');
|
||||||
final ss = (_secondsLeft % 60).toString().padLeft(2, '0');
|
final ss = (_secondsLeft % 60).toString().padLeft(2, '0');
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
// Steps
|
const Text('在钉钉中找到 iAgent 机器人,发送以下验证码:',
|
||||||
_buildStep('1', '打开钉钉,找到 IT0 机器人对话'),
|
textAlign: TextAlign.center,
|
||||||
const SizedBox(height: 10),
|
style: TextStyle(fontSize: 13, color: AppColors.textSecondary)),
|
||||||
_buildStep('2', '发送以下验证码(区分大小写)'),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Code display
|
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Clipboard.setData(ClipboardData(text: _code));
|
Clipboard.setData(ClipboardData(text: _code));
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('验证码已复制'), duration: Duration(seconds: 1)),
|
const SnackBar(content: Text('已复制'), duration: Duration(seconds: 1)));
|
||||||
);
|
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16),
|
||||||
|
|
@ -921,16 +1004,8 @@ class _DingTalkBindSheetState extends State<_DingTalkBindSheet> {
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(_code, style: const TextStyle(fontSize: 36, fontWeight: FontWeight.bold,
|
||||||
_code,
|
color: Color(0xFF1A73E8), letterSpacing: 6, fontFamily: 'monospace')),
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 36,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Color(0xFF1A73E8),
|
|
||||||
letterSpacing: 6,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
const Icon(Icons.copy_outlined, size: 16, color: Color(0xFF1A73E8)),
|
const Icon(Icons.copy_outlined, size: 16, color: Color(0xFF1A73E8)),
|
||||||
],
|
],
|
||||||
|
|
@ -938,31 +1013,10 @@ class _DingTalkBindSheetState extends State<_DingTalkBindSheet> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text('有效期 $mm:$ss',
|
||||||
'有效期 $mm:$ss',
|
style: TextStyle(fontSize: 12,
|
||||||
style: TextStyle(
|
color: _secondsLeft < 60 ? AppColors.error : AppColors.textMuted)),
|
||||||
fontSize: 12,
|
const SizedBox(height: 16),
|
||||||
color: _secondsLeft < 60 ? AppColors.error : AppColors.textMuted,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
|
|
||||||
// Open DingTalk button
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: OutlinedButton.icon(
|
|
||||||
icon: const Icon(Icons.open_in_new, size: 16),
|
|
||||||
label: const Text('打开钉钉'),
|
|
||||||
onPressed: _openDingTalk,
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: const Color(0xFF1A73E8),
|
|
||||||
side: const BorderSide(color: Color(0xFF1A73E8)),
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
const Row(
|
const Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -975,99 +1029,66 @@ class _DingTalkBindSheetState extends State<_DingTalkBindSheet> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStep(String num, String text) {
|
// ── Common widgets ─────────────────────────────────────────────────────────
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 22, height: 22,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFF1A73E8).withOpacity(0.15),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: Center(
|
|
||||||
child: Text(num, style: const TextStyle(fontSize: 12, color: Color(0xFF1A73E8), fontWeight: FontWeight.bold)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(child: Text(text, style: const TextStyle(fontSize: 13, color: AppColors.textSecondary))),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSuccess() {
|
Widget _buildSpinner(String label) => Padding(
|
||||||
return Column(
|
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||||
children: [
|
child: Column(children: [
|
||||||
|
const CircularProgressIndicator(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(label, style: const TextStyle(fontSize: 13, color: AppColors.textMuted)),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _buildSuccess() => Column(children: [
|
||||||
const Icon(Icons.check_circle_rounded, color: Color(0xFF22C55E), size: 64),
|
const Icon(Icons.check_circle_rounded, color: Color(0xFF22C55E), size: 64),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text('绑定成功!', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: AppColors.textPrimary)),
|
const Text('绑定成功!', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: AppColors.textPrimary)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text('「${widget.instance.name}」已与钉钉绑定\n现在可以直接在钉钉中与它对话了',
|
||||||
'「${widget.instance.name}」已与钉钉绑定\n现在可以直接在钉钉中与它对话了',
|
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(fontSize: 13, color: AppColors.textMuted, height: 1.6),
|
style: const TextStyle(fontSize: 13, color: AppColors.textMuted, height: 1.6)),
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
SizedBox(
|
SizedBox(width: double.infinity,
|
||||||
width: double.infinity,
|
|
||||||
child: FilledButton(
|
child: FilledButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: const Color(0xFF22C55E),
|
backgroundColor: const Color(0xFF22C55E),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))),
|
||||||
),
|
|
||||||
child: const Text('完成'),
|
child: const Text('完成'),
|
||||||
),
|
)),
|
||||||
),
|
]);
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildExpired() {
|
Widget _buildExpired() => Column(children: [
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.timer_off_outlined, color: AppColors.textMuted, size: 48),
|
const Icon(Icons.timer_off_outlined, color: AppColors.textMuted, size: 48),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const Text('验证码已过期', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
const Text('验证码已过期', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||||
const SizedBox(height: 8),
|
|
||||||
const Text('请重新获取验证码', style: TextStyle(fontSize: 13, color: AppColors.textMuted)),
|
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
SizedBox(
|
SizedBox(width: double.infinity,
|
||||||
width: double.infinity,
|
|
||||||
child: FilledButton(
|
child: FilledButton(
|
||||||
onPressed: _fetchCode,
|
onPressed: () => setState(() { _phase = _BindPhase.idle; }),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))),
|
||||||
),
|
child: const Text('重新开始'),
|
||||||
child: const Text('重新获取'),
|
)),
|
||||||
),
|
]);
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildError() {
|
Widget _buildError() => Column(children: [
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.error_outline, color: AppColors.error, size: 48),
|
const Icon(Icons.error_outline, color: AppColors.error, size: 48),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(_errorMsg ?? '获取验证码失败', style: const TextStyle(fontSize: 13, color: AppColors.error), textAlign: TextAlign.center),
|
Text(_errorMsg ?? '操作失败',
|
||||||
|
style: const TextStyle(fontSize: 13, color: AppColors.error), textAlign: TextAlign.center),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
SizedBox(
|
SizedBox(width: double.infinity,
|
||||||
width: double.infinity,
|
|
||||||
child: OutlinedButton(
|
child: OutlinedButton(
|
||||||
onPressed: _fetchCode,
|
onPressed: () => setState(() { _phase = _BindPhase.idle; }),
|
||||||
child: const Text('重试'),
|
child: const Text('重试'),
|
||||||
),
|
)),
|
||||||
),
|
]);
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum _BindPhase { loading, waitingCode, success, expired, error }
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Dismiss confirm dialog
|
// Dismiss confirm dialog
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,16 @@ services:
|
||||||
- /api/v1/admin
|
- /api/v1/admin
|
||||||
strip_path: false
|
strip_path: false
|
||||||
|
|
||||||
|
# Public DingTalk OAuth callback — no JWT (DingTalk redirects here after user taps Authorize)
|
||||||
|
# Must be declared BEFORE agent-service so Kong matches this specific path first.
|
||||||
|
- name: dingtalk-oauth-public
|
||||||
|
url: http://agent-service:3002
|
||||||
|
routes:
|
||||||
|
- name: dingtalk-oauth-callback
|
||||||
|
paths:
|
||||||
|
- /api/v1/agent/channels/dingtalk/oauth/callback
|
||||||
|
strip_path: false
|
||||||
|
|
||||||
- name: agent-service
|
- name: agent-service
|
||||||
url: http://agent-service:3002
|
url: http://agent-service:3002
|
||||||
routes:
|
routes:
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,9 @@ interface DtFrame {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BotMsg {
|
interface BotMsg {
|
||||||
|
/** openId — per-app unique, consistent with OAuth /contact/users/me response */
|
||||||
|
senderId: string;
|
||||||
|
/** staffId — enterprise employee ID (kept for backward compat) */
|
||||||
senderStaffId: string;
|
senderStaffId: string;
|
||||||
sessionWebhook: string;
|
sessionWebhook: string;
|
||||||
/**
|
/**
|
||||||
|
|
@ -68,7 +71,8 @@ interface BindingEntry {
|
||||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const DINGTALK_MAX_CHARS = 4800;
|
const DINGTALK_MAX_CHARS = 4800;
|
||||||
const CODE_TTL_MS = 5 * 60 * 1000; // 5 min
|
const CODE_TTL_MS = 15 * 60 * 1000; // 15 min
|
||||||
|
const OAUTH_STATE_TTL_MS = 10 * 60 * 1000; // 10 min
|
||||||
const TOKEN_REFRESH_BUFFER = 300; // seconds before expiry to proactively refresh
|
const TOKEN_REFRESH_BUFFER = 300; // seconds before expiry to proactively refresh
|
||||||
const WS_RECONNECT_BASE_MS = 2_000;
|
const WS_RECONNECT_BASE_MS = 2_000;
|
||||||
const WS_RECONNECT_MAX_MS = 60_000;
|
const WS_RECONNECT_MAX_MS = 60_000;
|
||||||
|
|
@ -104,6 +108,7 @@ export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
|
||||||
// State
|
// State
|
||||||
private readonly bindingCodes = new Map<string, BindingEntry>(); // code → entry
|
private readonly bindingCodes = new Map<string, BindingEntry>(); // code → entry
|
||||||
|
private readonly oauthStates = new Map<string, { instanceId: string; expiresAt: number }>(); // state → entry
|
||||||
private readonly dedup = new Map<string, number>(); // msgId → ts
|
private readonly dedup = new Map<string, number>(); // msgId → ts
|
||||||
private readonly rateWindows = new Map<string, number[]>(); // userId → timestamps
|
private readonly rateWindows = new Map<string, number[]>(); // userId → timestamps
|
||||||
private readonly queueTails = new Map<string, Promise<void>>(); // userId → tail
|
private readonly queueTails = new Map<string, Promise<void>>(); // userId → tail
|
||||||
|
|
@ -156,6 +161,72 @@ export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy {
|
||||||
return { code, expiresAt };
|
return { code, expiresAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── OAuth API ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a DingTalk OAuth authorization URL.
|
||||||
|
* The state token encodes the instanceId so the callback can complete binding
|
||||||
|
* without user interaction (one-tap "Authorize" in DingTalk on the same phone).
|
||||||
|
*/
|
||||||
|
generateOAuthUrl(instanceId: string): { oauthUrl: string; state: string } {
|
||||||
|
// Invalidate old states for this instance
|
||||||
|
for (const [s, entry] of this.oauthStates) {
|
||||||
|
if (entry.instanceId === instanceId) this.oauthStates.delete(s);
|
||||||
|
}
|
||||||
|
const state = crypto.randomBytes(16).toString('hex');
|
||||||
|
const expiresAt = Date.now() + OAUTH_STATE_TTL_MS;
|
||||||
|
this.oauthStates.set(state, { instanceId, expiresAt });
|
||||||
|
|
||||||
|
const baseUrl = this.configService.get<string>('IT0_BASE_URL', 'https://it0api.szaiai.com');
|
||||||
|
const redirectUri = `${baseUrl}/api/v1/agent/channels/dingtalk/oauth/callback`;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: this.clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
response_type: 'code',
|
||||||
|
scope: 'openid',
|
||||||
|
state,
|
||||||
|
prompt: 'consent',
|
||||||
|
});
|
||||||
|
return { state, oauthUrl: `https://login.dingtalk.com/oauth2/auth?${params.toString()}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete the OAuth flow: exchange auth code → get user openId → save to DB.
|
||||||
|
* Called by the public callback endpoint (no JWT).
|
||||||
|
*/
|
||||||
|
async completeOAuthBinding(code: string, state: string): Promise<{ instanceId: string; instanceName: string }> {
|
||||||
|
const entry = this.oauthStates.get(state);
|
||||||
|
if (!entry) throw new Error('无效或已过期的授权状态,请重新绑定');
|
||||||
|
if (Date.now() > entry.expiresAt) {
|
||||||
|
this.oauthStates.delete(state);
|
||||||
|
throw new Error('授权已超时,请重新绑定');
|
||||||
|
}
|
||||||
|
this.oauthStates.delete(state);
|
||||||
|
|
||||||
|
// Exchange auth code for user access token
|
||||||
|
const tokenResult = await this.httpsPost<{ accessToken: string; expireIn: number }>(
|
||||||
|
'api.dingtalk.com', '/v1.0/oauth2/userAccessToken',
|
||||||
|
{ clientId: this.clientId, clientSecret: this.clientSecret, code, grantType: 'authorization_code' },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get user's openId — this is the same as senderId in bot messages (per-app ID)
|
||||||
|
const userInfo = await this.httpsGet<{ openId: string; unionId: string }>(
|
||||||
|
'api.dingtalk.com', '/v1.0/contact/users/me',
|
||||||
|
{ 'x-acs-dingtalk-access-token': tokenResult.accessToken },
|
||||||
|
);
|
||||||
|
|
||||||
|
const dingTalkUserId = userInfo.openId;
|
||||||
|
if (!dingTalkUserId) throw new Error('无法获取钉钉用户身份,请重试');
|
||||||
|
|
||||||
|
const instance = await this.instanceRepo.findById(entry.instanceId);
|
||||||
|
if (!instance) throw new Error('智能体实例不存在');
|
||||||
|
instance.dingTalkUserId = dingTalkUserId;
|
||||||
|
await this.instanceRepo.save(instance);
|
||||||
|
|
||||||
|
this.logger.log(`OAuth binding: instance ${entry.instanceId} → DingTalk openId ${dingTalkUserId}`);
|
||||||
|
return { instanceId: entry.instanceId, instanceName: instance.name };
|
||||||
|
}
|
||||||
|
|
||||||
// ── Token management ───────────────────────────────────────────────────────
|
// ── Token management ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async getToken(): Promise<string> {
|
private async getToken(): Promise<string> {
|
||||||
|
|
@ -333,9 +404,11 @@ export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
private dispatchMessage(msg: BotMsg): void {
|
private dispatchMessage(msg: BotMsg): void {
|
||||||
const userId = msg.senderStaffId?.trim();
|
// senderId = openId (per-app), consistent with OAuth binding identifier.
|
||||||
|
// Fall back to senderStaffId for legacy messages that lack senderId.
|
||||||
|
const userId = (msg.senderId ?? msg.senderStaffId)?.trim();
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
this.logger.warn('Received message with empty senderStaffId, ignoring');
|
this.logger.warn('Received message with no sender ID, ignoring');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -566,10 +639,38 @@ export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy {
|
||||||
for (const [code, entry] of this.bindingCodes) {
|
for (const [code, entry] of this.bindingCodes) {
|
||||||
if (now > entry.expiresAt) this.bindingCodes.delete(code);
|
if (now > entry.expiresAt) this.bindingCodes.delete(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const [state, entry] of this.oauthStates) {
|
||||||
|
if (now > entry.expiresAt) this.oauthStates.delete(state);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── HTTP helpers ───────────────────────────────────────────────────────────
|
// ── HTTP helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** HTTPS GET to DingTalk API. Response body capped at MAX_RESPONSE_BYTES. */
|
||||||
|
private httpsGet<T>(hostname: string, path: string, headers: Record<string, string> = {}): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = https.request(
|
||||||
|
{ hostname, path, method: 'GET', headers: { ...headers }, timeout: 10_000 },
|
||||||
|
(res) => {
|
||||||
|
let data = ''; let totalBytes = 0;
|
||||||
|
res.on('data', (chunk: Buffer) => { totalBytes += chunk.length; if (totalBytes <= MAX_RESPONSE_BYTES) data += chunk.toString(); });
|
||||||
|
res.on('end', () => {
|
||||||
|
if (totalBytes > MAX_RESPONSE_BYTES) { reject(new Error(`Response too large (${totalBytes} bytes)`)); return; }
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(data);
|
||||||
|
if (res.statusCode && res.statusCode >= 400) reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`));
|
||||||
|
else resolve(json as T);
|
||||||
|
} catch (e) { reject(e); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
req.on('timeout', () => { req.destroy(); reject(new Error('DingTalk API GET timeout')); });
|
||||||
|
req.on('error', reject);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** HTTPS POST to DingTalk API. Response body capped at MAX_RESPONSE_BYTES. */
|
/** HTTPS POST to DingTalk API. Response body capped at MAX_RESPONSE_BYTES. */
|
||||||
private httpsPost<T>(
|
private httpsPost<T>(
|
||||||
hostname: string,
|
hostname: string,
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,13 @@ import {
|
||||||
Post,
|
Post,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
|
Query,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
|
BadRequestException,
|
||||||
|
Res,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { Response } from 'express';
|
||||||
import { DingTalkRouterService } from '../../../infrastructure/dingtalk/dingtalk-router.service';
|
import { DingTalkRouterService } from '../../../infrastructure/dingtalk/dingtalk-router.service';
|
||||||
import { AgentInstanceRepository } from '../../../infrastructure/repositories/agent-instance.repository';
|
import { AgentInstanceRepository } from '../../../infrastructure/repositories/agent-instance.repository';
|
||||||
|
|
||||||
|
|
@ -46,4 +50,66 @@ export class AgentChannelController {
|
||||||
await this.instanceRepo.save(inst);
|
await this.instanceRepo.save(inst);
|
||||||
return { unbound: true };
|
return { unbound: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate DingTalk OAuth binding.
|
||||||
|
* Returns the OAuth authorization URL — Flutter opens it via launchUrl.
|
||||||
|
* On the same phone, DingTalk intercepts the URL and shows a one-tap auth screen.
|
||||||
|
*/
|
||||||
|
@Get('dingtalk/oauth/init')
|
||||||
|
async oauthInit(@Query('instanceId') instanceId: string) {
|
||||||
|
if (!instanceId) throw new BadRequestException('instanceId is required');
|
||||||
|
if (!this.dingTalkRouter.isEnabled()) {
|
||||||
|
throw new ServiceUnavailableException('DingTalk integration not configured');
|
||||||
|
}
|
||||||
|
const inst = await this.instanceRepo.findById(instanceId);
|
||||||
|
if (!inst) throw new NotFoundException(`Instance ${instanceId} not found`);
|
||||||
|
|
||||||
|
const { oauthUrl, state } = this.dingTalkRouter.generateOAuthUrl(instanceId);
|
||||||
|
return { oauthUrl, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DingTalk OAuth callback — PUBLIC endpoint, no JWT.
|
||||||
|
* DingTalk redirects here after user taps "Authorize".
|
||||||
|
* Exchanges the auth code for openId, saves binding, returns success HTML.
|
||||||
|
*/
|
||||||
|
@Get('dingtalk/oauth/callback')
|
||||||
|
async oauthCallback(
|
||||||
|
@Query('code') code: string,
|
||||||
|
@Query('state') state: string,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
if (!code || !state) {
|
||||||
|
return res.status(400).send(this.htmlPage('参数错误', '缺少 code 或 state 参数,请重新绑定。', false));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { instanceName } = await this.dingTalkRouter.completeOAuthBinding(code, state);
|
||||||
|
return res.send(this.htmlPage(
|
||||||
|
'绑定成功 ✅',
|
||||||
|
`「${instanceName}」已成功与您的钉钉账号绑定!\n现在可以关闭此页面,在钉钉中与 iAgent 机器人对话了。`,
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
} catch (e: any) {
|
||||||
|
return res.status(400).send(this.htmlPage('绑定失败', e.message ?? '请返回 iAgent App 重新操作。', false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private htmlPage(title: string, message: string, success: boolean): string {
|
||||||
|
const color = success ? '#22C55E' : '#EF4444';
|
||||||
|
const icon = success ? '✅' : '❌';
|
||||||
|
return `<!DOCTYPE html><html lang="zh"><head><meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>${title}</title>
|
||||||
|
<style>body{font-family:-apple-system,sans-serif;background:#0F1117;color:#E2E8F0;
|
||||||
|
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||||
|
min-height:100vh;margin:0;padding:24px;text-align:center;}
|
||||||
|
h1{font-size:28px;color:${color};margin-bottom:12px;}
|
||||||
|
p{font-size:15px;line-height:1.7;color:#94A3B8;max-width:320px;white-space:pre-line;}
|
||||||
|
</style></head><body>
|
||||||
|
<div style="font-size:64px;margin-bottom:16px">${icon}</div>
|
||||||
|
<h1>${title}</h1>
|
||||||
|
<p>${message}</p>
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue