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.
 
 
 
 
 
 

234 lines
8.1 KiB

package translate
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/aliyun/alibaba-cloud-sdk-go/sdk"
"github.com/aliyun/alibaba-cloud-sdk-go/sdk/auth/credentials"
"github.com/aliyun/alibaba-cloud-sdk-go/sdk/requests"
)
// Aliyun Machine Translation (alimt) TranslateGeneral
// Docs: https://help.aliyun.com/document_detail/158244.html
type AliTranslate struct {
options Options
client *sdk.Client
}
func newSys(options Options) (sys *AliTranslate, err error) {
if options.AccessKeyId == "" || options.AccessKeySecret == "" {
return nil, fmt.Errorf("aliyun translate: AccessKeyId/AccessKeySecret required")
}
cfg := sdk.NewConfig()
cfg.HttpTransport = &http.Transport{IdleConnTimeout: 10 * time.Second}
cfg.Timeout = 15 * time.Second
cred := credentials.NewAccessKeyCredential(options.AccessKeyId, options.AccessKeySecret)
cli, err := sdk.NewClientWithOptions(options.Region, cfg, cred)
if err != nil {
return nil, fmt.Errorf("new sdk client: %w", err)
}
sys = &AliTranslate{
options: options,
client: cli,
}
return
}
// flexStr 兼容阿里同一个字段的两种形状:字符串或数字。
//
// ⚠️ 这不是洁癖,是一次真实事故(2026-09-19):TranslateGeneral 正常响应里
// `Code` 是字符串 "200",而**鉴权层**的错误(产品未开通)回的是**数字**:
//
// {"Message":"Auth check failed! ... service:[alimt] is not activated.","Code":10009}
//
// 字段声明成 string 时这里直接 unmarshal 失败,错误变成
// 「cannot unmarshal number into Go struct field ... of type string」——
// 真正的原因(产品没开通)被盖住,而且 comm.IsLangUnsupportedErr 那套靠匹配
// `code=10005` 分流的逻辑也一并失效,所有阿里翻译错误都退化成「请稍后再试」,
// 用户只会一直点重试。
type flexStr string
func (f *flexStr) UnmarshalJSON(b []byte) error {
b = bytes.TrimSpace(b)
if len(b) == 0 || string(b) == "null" {
*f = ""
return nil
}
if b[0] == '"' {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
*f = flexStr(s)
return nil
}
*f = flexStr(b) // 数字/布尔等,原样按字面量取
return nil
}
// translateGeneralResponse Aliyun MT TranslateGeneral 响应
type translateGeneralResponse struct {
RequestId string `json:"RequestId"`
Code flexStr `json:"Code"`
Message string `json:"Message,omitempty"`
Data struct {
WordCount flexStr `json:"WordCount"`
Translated string `json:"Translated"`
// SourceLanguage 传 auto 时,阿里会在这里回填它检测出的源语种(短码)。
// 文本翻译的「自动检测」靠它把语种显示回界面。
DetectedLanguage string `json:"DetectedLanguage"`
} `json:"Data"`
}
func (this *AliTranslate) translateOne(from, to, text string) (translated, detected string, err error) {
req := requests.NewCommonRequest()
req.Method = "POST"
req.Scheme = "https"
req.Domain = fmt.Sprintf("mt.%s.aliyuncs.com", this.options.Region)
req.ApiName = "TranslateGeneral"
req.Version = "2018-10-12"
req.QueryParams["FormatType"] = this.options.FormatType
req.QueryParams["SourceLanguage"] = from
req.QueryParams["TargetLanguage"] = to
req.QueryParams["SourceText"] = text
req.QueryParams["Scene"] = this.options.Scene
resp, err := this.client.ProcessCommonRequest(req)
if err != nil {
return "", "", fmt.Errorf("translate request: %w", err)
}
body := resp.GetHttpContentString()
var out translateGeneralResponse
if err = json.Unmarshal([]byte(body), &out); err != nil {
return "", "", fmt.Errorf("unmarshal response: %w body=%s", err, body)
}
if out.Code != "" && out.Code != "200" {
// 「产品没开通」是配置问题,重试一万次也没用,单独把话说明白,
// 免得只看到「翻译失败,请稍后再试」而一直等它自己好。
if out.Code == "10009" || strings.Contains(out.Message, "is not activated") {
return "", "", fmt.Errorf("translate failed: code=%s msg=%s(阿里云机器翻译 alimt 未开通:去阿里云控制台开通,或把后台 MT 服务换成别家)", out.Code, out.Message)
}
return "", "", fmt.Errorf("translate failed: code=%s msg=%s", out.Code, out.Message)
}
return out.Data.Translated, out.Data.DetectedLanguage, nil
}
// isThrottling 判断是否为阿里 MT 的用户级流控错误(ErrorCode: Throttling.User)。
// 这类错误是暂时的,退避后重试通常能成功。
func isThrottling(err error) bool {
if err == nil {
return false
}
s := err.Error()
return strings.Contains(s, "Throttling") || strings.Contains(s, "flow control")
}
// translateRetryBackoff 限流重试的退避间隔。阿里返回的 X-Acs-Retry-After 常达 80~170s,
// 全额等待会把一篇会议的翻译拖到十几分钟;这里用较短的递增退避,配合「失败降级为原文」兜底。
var translateRetryBackoff = []time.Duration{500 * time.Millisecond, 2 * time.Second, 5 * time.Second}
// translateOneWithRetry 单句翻译;遇到流控按 translateRetryBackoff 退避重试。
// 非流控错误(如参数错误)不重试,直接返回。
func (this *AliTranslate) translateOneWithRetry(ctx context.Context, from, to, text string) (translated, detected string, err error) {
var lastErr error
for attempt := 0; ; attempt++ {
r, d, e := this.translateOne(from, to, text)
if e == nil {
return r, d, nil
}
lastErr = e
if !isThrottling(e) || attempt >= len(translateRetryBackoff) {
return "", "", lastErr
}
select {
case <-ctx.Done():
return "", "", ctx.Err()
case <-time.After(translateRetryBackoff[attempt]):
}
}
}
// TranslateDetect 单句翻译并回传阿里检测出的源语种。
//
// from 传空串时按 "auto" 走自动检测(文本翻译的「自动检测」模式用这个);
// detected 是阿里回的短码(zh / en / ...),from 非空时通常为空。
func (this *AliTranslate) TranslateDetect(ctx context.Context, from, to, text string) (translated, detected string, err error) {
if strings.TrimSpace(from) == "" {
from = "auto"
}
return this.translateOneWithRetry(ctx, from, to, text)
}
// Translate 批量翻译,按 Concurrency 并发执行 TranslateGeneral。
// from / to 为阿里 MT 语言码:zh / en / ja / ko / ...(保持调用方传入的标准)
//
// 容错语义(调用方需注意):
// - 单句重试后仍失败,**用原文占位**,results 始终与 texts 等长;
// - 部分失败:返回 (results, err) —— results 可用,err 描述失败句数,调用方记日志后应继续;
// - 全部失败:返回 (nil, err) —— 视为硬失败。
//
// 这样一次限流不会把整篇会议的译文清空(进而让 AI 总结拿到空内容、报 content field is required)。
func (this *AliTranslate) Translate(ctx context.Context, from, to string, texts []string) (results []string, err error) {
if len(texts) == 0 {
return []string{}, nil
}
results = make([]string, len(texts))
errs := make([]error, len(texts))
sem := make(chan struct{}, this.options.Concurrency)
var wg sync.WaitGroup
for i, t := range texts {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
wg.Add(1)
sem <- struct{}{}
go func(idx int, text string) {
defer wg.Done()
defer func() { <-sem }()
// 空串/纯空白直接返回原值,阿里 MT 对空文本会报 code=10004
if strings.TrimSpace(text) == "" {
results[idx] = text
return
}
r, _, e := this.translateOneWithRetry(ctx, from, to, text)
if e != nil {
results[idx] = text // 降级:保留原文,别让整批作废
errs[idx] = e
return
}
results[idx] = r
}(i, t)
}
wg.Wait()
failed, firstIdx := 0, -1
var firstErr error
for i, e := range errs {
if e != nil {
failed++
if firstErr == nil {
firstErr, firstIdx = e, i
}
}
}
switch {
case failed == 0:
return results, nil
case failed == len(texts):
return nil, fmt.Errorf("translate all %d failed, first index=%d: %w", failed, firstIdx, firstErr)
default:
return results, fmt.Errorf("translate partial failure: %d/%d failed (已用原文占位), first index=%d: %w",
failed, len(texts), firstIdx, firstErr)
}
}