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.

545 lines
19 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,
};
}
// 文本翻译
/// [text] 待翻译的文本
/// [sourceLanguageCode] 源语言的ASR代码
/// [targetLanguageCode] 目标语言的ASR代码
/// [return] 翻译后的文本
/// 计费标准:每月200万字符免费,超出部分按49元/百万字符计费
Future<String?> translateText({
required String text,
required String sourceLanguageCode,
required String targetLanguageCode,
}) async {
if (!_isInitialized) {
print('🟡[VOLC-DBG] 翻译服务未初始化,返回 null');
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;
}
print('🟡[VOLC-DBG] translateText: srcAsr=$sourceLanguageCode->srcShort=$sourceCode, tgtAsr=$targetLanguageCode->tgtShort=$targetCode, text=$text');
// 构建请求体
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';
print('🟡[VOLC-DBG] 发起翻译请求: url=$url, body=${json.encode(requestBody)}');
final response = await http.post(
Uri.parse(url),
headers: headers,
body: json.encode(requestBody),
);
print('🟡[VOLC-DBG] 翻译响应: status=${response.statusCode}, bodyLen=${response.body.length}, body=${response.body.length > 500 ? response.body.substring(0, 500) : response.body}');
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'];
}
}
}
}
// 返回提取到的翻译结果
if (translation == null) {
print('🟡[VOLC-DBG] HTTP 200 但未能解析出翻译字段,responseData=$responseData');
} else {
print('🟡[VOLC-DBG] 翻译成功: $translation');
}
return translation;
}
print('🟡[VOLC-DBG] HTTP 错误: status=${response.statusCode}, body=${response.body}');
Logger.error(
'翻译HTTP错误: status=${response.statusCode}, body=${response.body}');
return null;
} catch (e, st) {
print('🟡[VOLC-DBG] 翻译请求异常: $e\n$st');
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;
}
}
/// 图片翻译(整图识别+回贴翻译)
/// [imageBytes] 待翻译的图片二进制数据
/// [targetLanguageCode] 目标语言的ASR代码
/// [return] 翻译结果
/// 计费标准:每月100张免费,超出部分按0.04元/张计费
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;
}
}
}