You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
525 lines
18 KiB
525 lines
18 KiB
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
|
import 'package:get/get.dart';
|
|
|
|
import '../../data/models/appconfig.dart';
|
|
import '../../core/utils/logger.dart';
|
|
import 'language_manager.dart';
|
|
import '../../modules/picture_translation/models/picture_translation_models.dart';
|
|
|
|
//// 火山引擎机器翻译服务
|
|
/// 提供文本翻译功能
|
|
/// 提供图片翻译功能
|
|
/// 提供文本语种检测功能
|
|
class VolcanoTranslationService extends GetxService {
|
|
// API 配置
|
|
late String _baseUrl;
|
|
final String _endpoint = '/'; // 修改为根路径
|
|
late final String _accessKey;
|
|
late final String _secretKey;
|
|
late final String _region;
|
|
late final String _service;
|
|
|
|
// 初始化标志
|
|
bool _isInitialized = false;
|
|
|
|
// 语言管理器
|
|
final LanguageManager _languageManager = Get.find<LanguageManager>();
|
|
|
|
// 文本语种检测频率控制与缓存
|
|
DateTime? _lastDetectTime; // 上次检测时间
|
|
String? _lastDetectTextKey; // 上次检测的文本键
|
|
final Duration _detectMinInterval =
|
|
const Duration(milliseconds: 800); // 最小检测间隔,避免过频请求
|
|
final Map<String, String> _detectCache = {}; // 文本键 -> 检测到的短代码 缓存
|
|
|
|
// 构造函数
|
|
VolcanoTranslationService();
|
|
@override
|
|
Future<void> onInit() async {
|
|
await initialize();
|
|
super.onInit();
|
|
}
|
|
|
|
// 对外暴露初始化状态,便于控制器判断
|
|
bool get isInitialized => _isInitialized;
|
|
|
|
// 初始化服务
|
|
Future<bool> initialize() async {
|
|
// 已初始化则直接返回,避免重复初始化
|
|
if (_isInitialized) {
|
|
Logger.info('翻译服务已初始化,跳过重复初始化');
|
|
return true;
|
|
}
|
|
try {
|
|
_accessKey = AppConfig.env('VOLCANO_TRANSLATION_ACCESS_KEY') ??
|
|
dotenv.env['VOLCANO_TRANSLATION_ACCESS_KEY'] ??
|
|
'';
|
|
_secretKey = AppConfig.env('VOLCANO_TRANSLATION_SECRET_KEY') ??
|
|
dotenv.env['VOLCANO_TRANSLATION_SECRET_KEY'] ??
|
|
'';
|
|
_region = AppConfig.env('VOLCANO_TRANSLATION_REGION') ??
|
|
dotenv.env['VOLCANO_TRANSLATION_REGION'] ??
|
|
'cn-north-1';
|
|
|
|
_service = 'translate';
|
|
_baseUrl = 'https://translate.volcengineapi.com';
|
|
|
|
if (_accessKey.isEmpty || _secretKey.isEmpty) {
|
|
Logger.error(
|
|
'lxm---Volcano Translation Service initialized失败,accessKey或secretKey为空');
|
|
return false;
|
|
}
|
|
|
|
_isInitialized = true;
|
|
Logger.info(
|
|
'lxm---Volcano Translation Service initialized成功, accessKey: $_accessKey, region: $_region, service: $_service,secretKey: $_secretKey');
|
|
return true;
|
|
} catch (e) {
|
|
Logger.error('lxm---Volcano Translation Service initialized失败,$e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// 生成签名
|
|
Map<String, String> _generateSignature(String method,
|
|
Map<String, dynamic> requestBody, Map<String, String> queryParams) {
|
|
// 1. 准备时间相关参数
|
|
final now = DateTime.now().toUtc();
|
|
final date = now.toIso8601String().split('T')[0].replaceAll('-', '');
|
|
final timestamp = now
|
|
.toIso8601String()
|
|
.replaceAll(':', '')
|
|
.replaceAll('-', '')
|
|
.split('.')[0] +
|
|
'Z';
|
|
|
|
// 2. 构建规范查询字符串 - 按照参数名ASCII排序
|
|
final sortedParams = Map.fromEntries(
|
|
queryParams.entries.toList()..sort((a, b) => a.key.compareTo(b.key)));
|
|
final List<String> queryParts = [];
|
|
sortedParams.forEach((key, value) {
|
|
// 使用RFC 3986编码,确保特殊字符被正确处理
|
|
final encodedKey = Uri.encodeComponent(key).replaceAll('+', '%20');
|
|
final encodedValue = Uri.encodeComponent(value).replaceAll('+', '%20');
|
|
queryParts.add('$encodedKey=$encodedValue');
|
|
});
|
|
final canonicalQueryString = queryParts.join('&');
|
|
|
|
// 3. 创建规范请求
|
|
final contentType = 'application/json';
|
|
final payloadHash =
|
|
sha256.convert(utf8.encode(json.encode(requestBody))).toString();
|
|
|
|
final host = 'translate.volcengineapi.com';
|
|
|
|
// 确保头部按照字典序排序,且键名小写
|
|
// 注意:只包含host和x-date头部,与示例保持一致
|
|
final canonicalHeaders = 'host:$host\nx-date:$timestamp\n';
|
|
final signedHeaders = 'host;x-date';
|
|
|
|
// 构建规范请求,格式必须与示例完全一致
|
|
final canonicalRequest =
|
|
'$method\n$_endpoint\n$canonicalQueryString\n$canonicalHeaders\n$signedHeaders\n$payloadHash';
|
|
|
|
// 4. 创建待签字符串
|
|
final algorithm = 'HMAC-SHA256';
|
|
final credentialScope = '$date/$_region/$_service/request';
|
|
final stringToSign =
|
|
'$algorithm\n$timestamp\n$credentialScope\n${sha256.convert(utf8.encode(canonicalRequest)).toString()}';
|
|
|
|
// 5. 计算签名 - 按照文档正确派生密钥
|
|
// 注意:这里是关键修复点,确保密钥派生过程完全符合文档
|
|
final kSecret = utf8.encode(_secretKey); // 直接使用原始密钥,不添加前缀
|
|
|
|
// kDate = HMAC(kSecret, Date)
|
|
var kDate = Hmac(sha256, kSecret).convert(utf8.encode(date)).bytes;
|
|
|
|
// kRegion = HMAC(kDate, Region)
|
|
var kRegion = Hmac(sha256, kDate).convert(utf8.encode(_region)).bytes;
|
|
|
|
// kService = HMAC(kRegion, Service)
|
|
var kService = Hmac(sha256, kRegion).convert(utf8.encode(_service)).bytes;
|
|
|
|
// kSigning = HMAC(kService, "request")
|
|
var kSigning = Hmac(sha256, kService).convert(utf8.encode('request')).bytes;
|
|
|
|
// 使用派生的签名密钥计算最终签名
|
|
final signature =
|
|
Hmac(sha256, kSigning).convert(utf8.encode(stringToSign)).toString();
|
|
|
|
// 6. 添加签名到请求头 - 格式必须与示例完全一致
|
|
final authorization =
|
|
'$algorithm Credential=$_accessKey/$credentialScope, SignedHeaders=$signedHeaders, Signature=$signature';
|
|
|
|
// 只返回必要的头部,与示例保持一致
|
|
return {
|
|
'Content-Type': contentType,
|
|
'X-Date': timestamp,
|
|
'Authorization': authorization,
|
|
'Host': host,
|
|
};
|
|
}
|
|
|
|
// 翻译文本
|
|
Future<String?> translateText({
|
|
required String text,
|
|
required String sourceLanguageCode,
|
|
required String targetLanguageCode,
|
|
}) async {
|
|
if (!_isInitialized) {
|
|
Logger.error('翻译服务未初始化');
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
// 获取源语言和目标语言的短代码
|
|
String sourceCode = '';
|
|
String targetCode = 'zh';
|
|
|
|
// 从 ASR 代码获取短代码
|
|
if (sourceLanguageCode.isNotEmpty) {
|
|
final sourceShortCode =
|
|
_languageManager.getShortCodeByAsrCode(sourceLanguageCode);
|
|
if (sourceShortCode != null) {
|
|
sourceCode = sourceShortCode;
|
|
}
|
|
}
|
|
|
|
final targetShortCode =
|
|
_languageManager.getShortCodeByAsrCode(targetLanguageCode);
|
|
if (targetShortCode != null) {
|
|
targetCode = targetShortCode;
|
|
}
|
|
|
|
// 构建请求体
|
|
final requestBody = {
|
|
'SourceLanguage': sourceCode,
|
|
'TargetLanguage': targetCode,
|
|
'TextList': [text],
|
|
};
|
|
|
|
// 添加必要的查询参数 - 确保包含所有必需参数
|
|
final queryParams = {
|
|
'Action': 'TranslateText',
|
|
'Version': '2020-06-01',
|
|
'Region': _region,
|
|
'Service': _service,
|
|
};
|
|
|
|
// 生成包含查询参数的签名
|
|
final headers = _generateSignature('POST', requestBody, queryParams);
|
|
|
|
// 构建带有查询参数的URL
|
|
final queryString = Uri(queryParameters: queryParams).query;
|
|
final url = '$_baseUrl/?$queryString';
|
|
|
|
final response = await http.post(
|
|
Uri.parse(url),
|
|
headers: headers,
|
|
body: json.encode(requestBody),
|
|
);
|
|
|
|
if (response.statusCode == 200) {
|
|
final responseData = json.decode(response.body);
|
|
|
|
// 尝试从不同的响应结构中提取翻译结果
|
|
String? translation;
|
|
|
|
// 1. 标准响应结构
|
|
if (responseData['TranslationList'] != null &&
|
|
responseData['TranslationList'] is List &&
|
|
responseData['TranslationList'].isNotEmpty) {
|
|
final translationItem = responseData['TranslationList'][0];
|
|
if (translationItem is Map &&
|
|
translationItem.containsKey('Translation')) {
|
|
translation = translationItem['Translation'];
|
|
}
|
|
}
|
|
|
|
// 2. 检查是否有错误响应
|
|
if (translation == null &&
|
|
responseData['ResponseMetadata'] != null &&
|
|
responseData['ResponseMetadata'] is Map &&
|
|
responseData['ResponseMetadata']['Error'] != null) {
|
|
return null;
|
|
}
|
|
|
|
// 3. 尝试其他可能的响应结构
|
|
if (translation == null) {
|
|
if (responseData.containsKey('Translation')) {
|
|
translation = responseData['Translation'];
|
|
} else if (responseData.containsKey('Result') &&
|
|
responseData['Result'] is Map &&
|
|
responseData['Result'].containsKey('Translation')) {
|
|
translation = responseData['Result']['Translation'];
|
|
} else if (responseData.containsKey('Data') &&
|
|
responseData['Data'] is Map) {
|
|
final data = responseData['Data'];
|
|
if (data.containsKey('Translation')) {
|
|
translation = data['Translation'];
|
|
} else if (data.containsKey('TranslationList') &&
|
|
data['TranslationList'] is List &&
|
|
data['TranslationList'].isNotEmpty) {
|
|
final item = data['TranslationList'][0];
|
|
if (item is Map && item.containsKey('Translation')) {
|
|
translation = item['Translation'];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 返回提取到的翻译结果
|
|
return translation;
|
|
}
|
|
|
|
Logger.error(
|
|
'翻译HTTP错误: status=${response.statusCode}, body=${response.body}');
|
|
return null;
|
|
} catch (e) {
|
|
Logger.error('翻译请求异常: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// 文本语种检测
|
|
/// 返回值为 ASR 代码(例如 `zh-CN` / `en-US`),若检测失败返回 null。
|
|
/// 频率控制:同一文本键在最小间隔内复用缓存结果,降低请求频率。
|
|
Future<String?> detectTextLanguage({
|
|
required String text,
|
|
}) async {
|
|
if (!_isInitialized) {
|
|
Logger.error('语种检测服务未初始化');
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
// 构建文本键用于频率控制与缓存:去除首尾空格并截取前 256 字符
|
|
final normalized = text.trim();
|
|
if (normalized.isEmpty) {
|
|
return null;
|
|
}
|
|
final textKey =
|
|
normalized.length > 256 ? normalized.substring(0, 256) : normalized;
|
|
|
|
// 命中缓存直接返回,避免重复请求
|
|
final cachedShortCode = _detectCache[textKey];
|
|
if (cachedShortCode != null && cachedShortCode.isNotEmpty) {
|
|
final cachedAsr =
|
|
_languageManager.getAsrCodeByShortCode(cachedShortCode);
|
|
if (cachedAsr != null && cachedAsr.isNotEmpty) {
|
|
return cachedAsr;
|
|
}
|
|
}
|
|
|
|
// 频率控制:同一文本键在最小时间间隔内直接复用上次结果或跳过
|
|
final now = DateTime.now();
|
|
if (_lastDetectTime != null && _lastDetectTextKey == textKey) {
|
|
final delta = now.difference(_lastDetectTime!);
|
|
if (delta < _detectMinInterval) {
|
|
Logger.info('语种检测过于频繁,已短路本次请求');
|
|
return null;
|
|
}
|
|
}
|
|
_lastDetectTime = now;
|
|
_lastDetectTextKey = textKey;
|
|
|
|
// 构建请求体
|
|
final requestBody = {
|
|
'TextList': [normalized],
|
|
};
|
|
|
|
// 查询参数(官方文档:Action=LangDetect, Version=2020-06-01)
|
|
final queryParams = {
|
|
'Action': 'LangDetect',
|
|
'Version': '2020-06-01',
|
|
'Region': _region,
|
|
'Service': _service,
|
|
};
|
|
|
|
// 生成签名与头部
|
|
final headers = _generateSignature('POST', requestBody, queryParams);
|
|
|
|
// 拼接请求 URL
|
|
final queryString = Uri(queryParameters: queryParams).query;
|
|
final url = '$_baseUrl/?$queryString';
|
|
|
|
// 发送请求,增加网络超时保护
|
|
final response = await http
|
|
.post(
|
|
Uri.parse(url),
|
|
headers: headers,
|
|
body: json.encode(requestBody),
|
|
)
|
|
.timeout(const Duration(seconds: 8));
|
|
|
|
if (response.statusCode == 200) {
|
|
final responseData = json.decode(response.body);
|
|
|
|
// 有效性校验:必须存在 DetectedLanguageList 且包含 Language/Confidence
|
|
if (responseData is! Map ||
|
|
responseData['DetectedLanguageList'] is! List ||
|
|
(responseData['DetectedLanguageList'] as List).isEmpty) {
|
|
Logger.error('语种检测响应无有效结果: ${response.body}');
|
|
return null;
|
|
}
|
|
|
|
// 选择置信度最高的结果
|
|
String? bestShortCode;
|
|
double bestConfidence = -1;
|
|
for (final item in (responseData['DetectedLanguageList'] as List)) {
|
|
if (item is Map) {
|
|
final lang = item['Language']?.toString();
|
|
final conf = (item['Confidence'] is num)
|
|
? (item['Confidence'] as num).toDouble()
|
|
: null;
|
|
if (lang != null && lang.isNotEmpty && conf != null) {
|
|
if (conf > bestConfidence) {
|
|
bestConfidence = conf;
|
|
bestShortCode = lang;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 置信度阈值校验:过低则视为失败
|
|
if (bestShortCode == null || bestConfidence < 0.6) {
|
|
Logger.info('语种检测置信度不足: code=$bestShortCode, conf=$bestConfidence');
|
|
return null;
|
|
}
|
|
|
|
// 映射为 ASR 代码
|
|
final asrCode = _languageManager.getAsrCodeByShortCode(bestShortCode);
|
|
if (asrCode == null || asrCode.isEmpty) {
|
|
Logger.error('语种检测返回短代码无法映射为ASR: $bestShortCode');
|
|
return null;
|
|
}
|
|
|
|
// 写入缓存(短代码缓存,便于后续复用)
|
|
_detectCache[textKey] = bestShortCode;
|
|
|
|
Logger.info(
|
|
'lxm---语种检测成功: $bestShortCode (conf=$bestConfidence) -> $asrCode');
|
|
return asrCode;
|
|
}
|
|
|
|
Logger.error(
|
|
'语种检测HTTP错误: status=${response.statusCode}, body=${response.body}');
|
|
return null;
|
|
} catch (e) {
|
|
Logger.error('语种检测请求异常: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// 图片翻译(整图识别+回贴翻译)
|
|
Future<ImageTranslationResult?> translateImage({
|
|
required Uint8List imageBytes,
|
|
required String targetLanguageCode, // ASR代码,如 zh-CN / en-US
|
|
}) async {
|
|
if (!_isInitialized) {
|
|
Logger.error('图片翻译服务未初始化');
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
// 目标语言短代码(例如 zh / en)
|
|
String targetCode = 'zh';
|
|
final targetShortCode =
|
|
_languageManager.getShortCodeByAsrCode(targetLanguageCode);
|
|
if (targetShortCode != null && targetShortCode.isNotEmpty) {
|
|
targetCode = targetShortCode;
|
|
}
|
|
|
|
// 将图片二进制转为base64字符串(不带前缀)
|
|
final imgBase64 = base64.encode(imageBytes);
|
|
|
|
// 构建请求体(根据官方文档)
|
|
final requestBody = {
|
|
'Image': imgBase64,
|
|
'TargetLanguage': targetCode,
|
|
};
|
|
|
|
// 查询参数(官方示例仅包含 Action/Version;为保持与文本翻译一致,保留 Region/Service)
|
|
final queryParams = {
|
|
'Action': 'TranslateImage',
|
|
'Version': '2020-07-01',
|
|
'Region': _region,
|
|
'Service': _service,
|
|
};
|
|
|
|
// 生成签名与请求头
|
|
final headers = _generateSignature('POST', requestBody, queryParams);
|
|
|
|
// 拼接请求URL
|
|
final queryString = Uri(queryParameters: queryParams).query;
|
|
final url = '$_baseUrl/?$queryString';
|
|
|
|
final response = await http.post(
|
|
Uri.parse(url),
|
|
headers: headers,
|
|
body: json.encode(requestBody),
|
|
);
|
|
|
|
if (response.statusCode == 200) {
|
|
final responseData = json.decode(response.body);
|
|
|
|
// 错误元数据检测
|
|
if (responseData is Map &&
|
|
responseData['ResponseMetadata'] is Map &&
|
|
(responseData['ResponseMetadata']['Error'] != null)) {
|
|
final err = responseData['ResponseMetadata']['Error'];
|
|
Logger.error(
|
|
'图片翻译错误: code=${err['Code']}, message=${err['Message']}');
|
|
return null;
|
|
}
|
|
|
|
// 解析回贴图片
|
|
Uint8List? translatedImage;
|
|
if (responseData['Image'] is String) {
|
|
try {
|
|
translatedImage = base64.decode(responseData['Image']);
|
|
} catch (_) {
|
|
translatedImage = null;
|
|
}
|
|
}
|
|
|
|
// 解析文本块
|
|
final List<ImageTextBlock> blocks = [];
|
|
if (responseData['TextBlocks'] is List) {
|
|
for (final item in (responseData['TextBlocks'] as List)) {
|
|
if (item is Map<String, dynamic>) {
|
|
blocks.add(ImageTextBlock.fromJson(item));
|
|
} else if (item is Map) {
|
|
blocks.add(ImageTextBlock.fromJson(
|
|
item.map((k, v) => MapEntry(k.toString(), v))));
|
|
}
|
|
}
|
|
}
|
|
|
|
// 提取一个整体检测语种(若存在文本块)
|
|
String? detectedLang;
|
|
if (blocks.isNotEmpty && blocks.first.detectedLanguage.isNotEmpty) {
|
|
detectedLang = blocks.first.detectedLanguage;
|
|
}
|
|
|
|
return ImageTranslationResult(
|
|
translatedImageBytes: translatedImage,
|
|
blocks: blocks,
|
|
detectedLanguage: detectedLang,
|
|
);
|
|
}
|
|
|
|
Logger.error(
|
|
'图片翻译HTTP错误: status=${response.statusCode}, body=${response.body}');
|
|
return null;
|
|
} catch (e) {
|
|
Logger.error('图片翻译请求异常: $e');
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|