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.

396 lines
13 KiB

import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import '../../data/models/appconfig.dart';
import 'package:get_storage/get_storage.dart';
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 '../../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 String _accessKey;
late String _secretKey;
late String _region;
late String _service;
// 初始化标志
bool _isInitialized = false;
// 语言管理器
final LanguageManager _languageManager = Get.find<LanguageManager>();
// final GetStorage _storage = GetStorage();
// 构造函数
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 {
// 优先使用后端 AppConfig,其次回退到 .env
final bool appConfigReady = AppConfig.isInitialized();
final accessKeyFromConfig = appConfigReady
? AppConfig.env('VOLCANO_TRANSLATION_ACCESS_KEY')
: null;
final secretKeyFromConfig = appConfigReady
? AppConfig.env('VOLCANO_TRANSLATION_SECRET_KEY')
: null;
final regionFromConfig =
appConfigReady ? AppConfig.env('VOLCANO_TRANSLATION_REGION') : null;
_accessKey = accessKeyFromConfig ??
dotenv.env['VOLCANO_TRANSLATION_ACCESS_KEY'] ??
'';
_secretKey = secretKeyFromConfig ??
dotenv.env['VOLCANO_TRANSLATION_SECRET_KEY'] ??
'';
_region = regionFromConfig ??
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;
}
}
/// 图片翻译(整图识别+回贴翻译)
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;
}
}
}