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
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;
|
|
}
|
|
}
|
|
}
|
|
|