// DualCheck API 封装 import 'dart:convert'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:dio/dio.dart'; // ── URL ── const String _baseUrl = kIsWeb ? 'http://localhost:8080' : 'http://127.0.0.1:8080'; const String _wsBaseUrl = kIsWeb ? 'ws://localhost:8080' : 'ws://127.0.0.1:8080'; class DualCheckApi { DualCheckApi._(); static final DualCheckApi instance = DualCheckApi._(); final Dio _dio = Dio(BaseOptions( baseUrl: _baseUrl, contentType: 'application/json', connectTimeout: const Duration(seconds: 5), receiveTimeout: const Duration(seconds: 10), sendTimeout: const Duration(seconds: 10), )) ..interceptors.add(_BizErrorInterceptor()); // ── Create ── Future createSession({ required String nickname, required String browserId, }) async { final res = await _dio.post>( '/api/v1/dual-check/create', data: { 'nickname': nickname, 'browser_id': browserId, }, ); final d = res.data!['data'] as Map; return CreateResp( sessionId: d['session_id'] as String, nickname: d['nickname'] as String, ttl: d['ttl_seconds'] as int, ); } // ── Submit ── Future submit({ required String sessionId, required String nickname, required String role, // "initiator" | "participant" required String text, }) async { final res = await _dio.post>( '/api/v1/dual-check/submit', data: { 'session_id': sessionId, 'nickname': nickname, 'role': role, 'text': text, }, ); final d = res.data!['data'] as Map; return SubmitResp( submitted: d['submitted'] as bool, initiatorReady: d['initiator_ready'] as bool, match: d['match'] as bool?, ); } // ── CheckSession ── Future checkSession(String sessionId) async { final res = await _dio.post>( '/api/v1/dual-check/check', data: {'session_id': sessionId}, ); final d = res.data!['data'] as Map; return CheckResp( exists: d['exists'] as bool, initiatorReady: d['initiator_ready'] as bool, participantCount: d['participant_count'] as int, ttlSeconds: d['ttl_seconds'] as int, ); } // ── GetSession 全量快照 ── Future getSession(String sessionId) async { final res = await _dio.post>( '/api/v1/dual-check/session', data: {'session_id': sessionId}, ); return parseSession(res.data!['data'] as Map); } // ── Join 参与者加入登记 ── Future> joinParticipant({ required String sessionId, required String nickname, }) async { final res = await _dio.post>( '/api/v1/dual-check/join', data: {'session_id': sessionId, 'nickname': nickname}, ); return res.data!['data'] as Map; } // ── SetResubmit 发起方切换"允许参与者重新提交" ── Future setResubmit(String sessionId, {required bool allow}) async { final res = await _dio.post>( '/api/v1/dual-check/resubmit', data: {'session_id': sessionId, 'allow': allow}, ); return parseSession(res.data!['data'] as Map); } // ── SetIgnoreWs 发起方切换"忽略首尾空白" ── Future setIgnoreWs(String sessionId, {required bool ignore}) async { final res = await _dio.post>( '/api/v1/dual-check/ignore-ws', data: {'session_id': sessionId, 'ignore': ignore}, ); return parseSession(res.data!['data'] as Map); } // ── EndSession 发起方主动结束会话 ── Future endSession(String sessionId) async { await _dio.post('/api/v1/dual-check/end', data: {'session_id': sessionId}); } static SessionResp parseSession(Map d) { final parts = (d['participants'] as List) .map((p) => ParticipantResp.fromMap(p as Map)) .toList(); return SessionResp( sessionId: d['session_id'] as String, initiatorNickname: (d['initiator_nickname'] ?? '') as String, initiatorReady: d['initiator_ready'] as bool, allowResubmit: (d['allow_resubmit'] as bool?) ?? false, ignoreWs: (d['ignore_ws'] as bool?) ?? false, participants: parts, ttlSeconds: d['ttl_seconds'] as int, ); } // ── WS URL ── String wsUrl({ required String sessionId, required String role, required String nickname, required String browserId, }) { return '$_wsBaseUrl/api/v1/dual-check/ws' '?session_id=${Uri.encodeQueryComponent(sessionId)}' '&role=$role' '&nickname=${Uri.encodeQueryComponent(nickname)}' '&browser_id=${Uri.encodeQueryComponent(browserId)}'; } } // ── Dio 拦截器:把后端业务错误转成 DioException ── class _BizErrorInterceptor extends Interceptor { @override void onResponse(Response response, ResponseInterceptorHandler handler) { final data = response.data; if (data is Map) { final code = data['code']; if (code != null && code != 0) { final msg = data['message']?.toString() ?? '请求失败'; handler.reject(DioException( requestOptions: response.requestOptions, response: response, error: _BizError(code as int, msg), type: DioExceptionType.badResponse, )); return; } } handler.next(response); } } class _BizError { final int code; final String message; const _BizError(this.code, this.message); @override String toString() => 'BusinessError($code): $message'; } bool isBizError(DioException e) => e.error is _BizError; int? bizErrorCode(DioException e) { final err = e.error; return err is _BizError ? err.code : null; } String bizErrorMessage(DioException e) { final err = e.error; return err is _BizError ? err.message : (e.message ?? '请求失败'); } // ── 数据模型 ── class CreateResp { final String sessionId; final String nickname; final int ttl; CreateResp({required this.sessionId, required this.nickname, required this.ttl}); } class SubmitResp { final bool submitted; final bool initiatorReady; final bool? match; SubmitResp({required this.submitted, required this.initiatorReady, this.match}); } class CheckResp { final bool exists; final bool initiatorReady; final int participantCount; final int ttlSeconds; CheckResp({ required this.exists, required this.initiatorReady, required this.participantCount, required this.ttlSeconds, }); } class ParticipantResp { final String nickname; final bool joined; final bool submitted; final bool? match; ParticipantResp({ required this.nickname, required this.joined, required this.submitted, this.match, }); factory ParticipantResp.fromMap(Map m) => ParticipantResp( nickname: m['nickname'] as String, joined: (m['joined'] as bool?) ?? true, submitted: (m['submitted'] as bool?) ?? false, match: m['match'] as bool?, ); } class SessionResp { final String sessionId; final String initiatorNickname; final bool initiatorReady; final bool allowResubmit; final bool ignoreWs; final List participants; final int ttlSeconds; SessionResp({ required this.sessionId, required this.initiatorNickname, required this.initiatorReady, required this.allowResubmit, required this.ignoreWs, required this.participants, required this.ttlSeconds, }); SessionResp copyWith({ String? sessionId, String? initiatorNickname, bool? initiatorReady, bool? allowResubmit, bool? ignoreWs, List? participants, int? ttlSeconds, }) => SessionResp( sessionId: sessionId ?? this.sessionId, initiatorNickname: initiatorNickname ?? this.initiatorNickname, initiatorReady: initiatorReady ?? this.initiatorReady, allowResubmit: allowResubmit ?? this.allowResubmit, ignoreWs: ignoreWs ?? this.ignoreWs, participants: participants ?? this.participants, ttlSeconds: ttlSeconds ?? this.ttlSeconds, ); } // ── WS 消息解析 ── class WSMessage { final String type; final Map data; final String error; WSMessage({required this.type, required this.data, this.error = ''}); factory WSMessage.fromJson(String raw) { try { final m = jsonDecode(raw) as Map; return WSMessage( type: m['type'] as String? ?? '', data: (m['data'] as Map?) ?? {}, error: (m['error'] as String?) ?? '', ); } catch (_) { return WSMessage(type: '', data: {}); } } }