---
tags: [工具/API, 运维, 自部署, TTS, Pot, Lingva]
created: 2026-07-20
---

# Pot 兼容 Lingva TTS API 部署文档

> 部署日期：2026-07-20  
> 目标软件：Pot  
> 服务器：阿里云 `47.76.161.183`  
> API 域名：`lingva.haudios.com`

## 1. 部署结果

本次在阿里云服务器上部署了一个轻量级、兼容 Pot Lingva TTS 插件的 API。

Pot 中使用的请求地址：

```text
https://lingva.haudios.com/pot-67a8cf0de7c5655699e1acc6ddd5e703
```

Pot 会自动在请求地址后拼接：

```text
/api/v1/audio/{语言代码}/{URL编码后的文本}
```

完整示例：

```text
https://lingva.haudios.com/pot-67a8cf0de7c5655699e1acc6ddd5e703/api/v1/audio/en/Normal
```

健康检查地址：

```text
https://lingva.haudios.com/pot-67a8cf0de7c5655699e1acc6ddd5e703/health
```

正常返回：

```json
{"status":"ok"}
```

## 2. 为什么没有使用官方 Lingva Docker 镜像

官方部署方式使用：

```text
thedaviddelta/lingva-translate:latest
```

但是本服务器只有约 `403 MiB` 内存，同时还运行着 Caddy、RustDesk、FRP、Mosquitto 等服务。官方 Lingva 是完整的 Next.js 应用，首次启动造成了严重的内存换页和 SSH 响应变慢。

因此本次改成了轻量 Python API：

- 常驻内存约 `11–13 MiB`
- 不依赖第三方 Python 包
- 通过系统 `curl` 请求 Google Translate TTS
- 返回 Pot Lingva 插件需要的 JSON 字节数组
- 最多同时处理 4 个请求
- 单次文本最长 1200 字符
- 长文本按约 180 字符自动分段
- 仅监听 `127.0.0.1:3000`
- 由 Caddy 提供公网 HTTPS

## 3. 最终架构

```text
Pot
  │
  │ HTTPS
  ▼
lingva.haudios.com
  │
  │ Caddy 专用路径反向代理
  ▼
127.0.0.1:3000
  │
  │ 轻量 Python API
  ▼
Google Translate TTS
  │
  ▼
{"audio":[音频字节数组]}
```

公网不能直接访问服务器的 `3000` 端口，只有 Caddy 能访问本地 API。

## 4. DNS 配置

在 `haudios.com` 的 DNS 控制台新增：

| 类型 | 主机记录 | 记录值 | TTL |
|---|---|---|---|
| A | `lingva` | `47.76.161.183` | 60 或默认值 |

不要给 `lingva.haudios.com` 添加 AAAA 记录，除非服务器确实配置了可用的公网 IPv6。

如果域名继承了错误的通配符 AAAA 记录，客户端可能先尝试不可达的 IPv6，表现为：

```text
unexpected EOF during handshake
```

Windows 可以清理本机 DNS 缓存：

```powershell
ipconfig /flushdns
```

查询 DNS：

```powershell
Resolve-DnsName lingva.haudios.com -Type A
Resolve-DnsName lingva.haudios.com -Type AAAA
```

正确状态：

- A 返回 `47.76.161.183`
- AAAA 查询不返回 IPv6 地址

## 5. 环境检查

连接服务器后检查系统：

```bash
cat /etc/os-release
uname -m
free -h
df -h /
python3 --version
caddy version
ss -lntup
```

本次服务器环境：

```text
Alibaba Cloud Linux 3
x86_64
Python 3.6.8
Caddy 2.6.4
```

测试服务器能否访问 Google：

```bash
curl -I --max-time 10 https://translate.google.com
```

必须能够得到 HTTP 响应，否则 TTS API 无法工作。

## 6. 创建轻量 API

创建目录：

```bash
install -d -m 0755 /opt/lingva-tts-api
```

创建：

```text
/opt/lingva-tts-api/server.py
```

内容如下：

```python
#!/usr/bin/env python3
"""Small Pot-compatible Lingva TTS API backed by Google Translate TTS."""

import argparse
import json
import re
import subprocess
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from socketserver import ThreadingMixIn
from urllib.parse import unquote, urlsplit


GOOGLE_TTS_URL = "https://translate.googleapis.com/translate_tts"
LANGUAGE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{0,19}$")
REQUEST_SLOTS = threading.BoundedSemaphore(4)
MAX_TEXT_LENGTH = 1200
GOOGLE_CHUNK_LENGTH = 180

LANGUAGE_ALIASES = {
    "zh_HANT": "zh-TW",
    "zh-HANT": "zh-TW",
    "zh_TW": "zh-TW",
    "zh_CN": "zh-CN",
}


class UpstreamError(Exception):
    pass


def split_text(text, limit=GOOGLE_CHUNK_LENGTH):
    """Split long text at natural boundaries while respecting Google's limit."""
    chunks = []
    remaining = text.strip()
    boundaries = set(" \t\r\n,.;:!?，。；：！？、")

    while remaining:
        if len(remaining) <= limit:
            chunks.append(remaining)
            break

        cut = limit
        for index in range(limit, max(limit - 60, 0), -1):
            if remaining[index - 1] in boundaries:
                cut = index
                break

        chunk = remaining[:cut].strip()
        if chunk:
            chunks.append(chunk)
        remaining = remaining[cut:].strip()

    return chunks


def fetch_audio(text, language):
    language = LANGUAGE_ALIASES.get(language, language.replace("_", "-"))
    audio_parts = []

    for chunk in split_text(text):
        command = [
            "/usr/bin/curl",
            "--fail",
            "--silent",
            "--show-error",
            "--location",
            "--max-time",
            "20",
            "--get",
            GOOGLE_TTS_URL,
            "--header",
            "Accept: audio/mpeg,*/*;q=0.8",
            "--data-urlencode",
            "ie=UTF-8",
            "--data-urlencode",
            "client=gtx",
            "--data-urlencode",
            "tl=" + language,
            "--data-urlencode",
            "q=" + chunk,
        ]
        try:
            result = subprocess.run(
                command,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                timeout=25,
            )
        except subprocess.TimeoutExpired:
            raise UpstreamError("upstream request timed out")

        if result.returncode != 0:
            raise UpstreamError("upstream request failed")

        payload = result.stdout
        if len(payload) < 100:
            raise UpstreamError("upstream did not return audio")
        audio_parts.append(payload)

    return b"".join(audio_parts)


class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
    daemon_threads = True
    allow_reuse_address = True


class TTSHandler(BaseHTTPRequestHandler):
    server_version = "PotLingvaTTS/1.1"

    def log_message(self, fmt, *args):
        print(
            '%s - - [%s] %s'
            % (self.client_address[0], self.log_date_time_string(), fmt % args),
            flush=True,
        )

    def send_json(self, status, value):
        body = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode(
            "utf-8"
        )
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-store")
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("X-Content-Type-Options", "nosniff")
        self.end_headers()
        self.wfile.write(body)

    def do_OPTIONS(self):
        self.send_response(204)
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
        self.send_header("Content-Length", "0")
        self.end_headers()

    def do_GET(self):
        path = urlsplit(self.path).path

        if path == "/health":
            self.send_json(200, {"status": "ok"})
            return

        parts = path.split("/", 5)
        if len(parts) != 6 or parts[1:4] != ["api", "v1", "audio"]:
            self.send_json(404, {"error": "not found"})
            return

        language = unquote(parts[4]).strip()
        text = unquote(parts[5]).strip()

        if not LANGUAGE_RE.match(language):
            self.send_json(400, {"error": "invalid language"})
            return
        if not text:
            self.send_json(400, {"error": "text is empty"})
            return
        if len(text) > MAX_TEXT_LENGTH:
            self.send_json(
                413,
                {"error": "text exceeds {} characters".format(MAX_TEXT_LENGTH)},
            )
            return

        if not REQUEST_SLOTS.acquire(False):
            self.send_json(503, {"error": "server is busy"})
            return

        try:
            audio = fetch_audio(text, language)
            self.send_json(200, {"audio": list(audio)})
        except UpstreamError as error:
            self.send_json(502, {"error": "upstream connection failed"})
            print("upstream error: {!r}".format(error), flush=True)
        except Exception as error:
            self.send_json(500, {"error": "audio generation failed"})
            print("request error: {!r}".format(error), flush=True)
        finally:
            REQUEST_SLOTS.release()


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--host", default="127.0.0.1")
    parser.add_argument("--port", default=3000, type=int)
    args = parser.parse_args()

    server = ThreadingHTTPServer((args.host, args.port), TTSHandler)
    print(
        "Pot-compatible TTS API listening on {}:{}".format(args.host, args.port),
        flush=True,
    )
    server.serve_forever()


if __name__ == "__main__":
    main()
```

检查 Python 语法：

```bash
python3 -m py_compile /opt/lingva-tts-api/server.py
```

## 7. 配置 systemd

创建：

```text
/etc/systemd/system/lingva-tts-api.service
```

内容：

```ini
[Unit]
Description=Lightweight Pot-compatible Lingva TTS API
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
WorkingDirectory=/opt/lingva-tts-api
ExecStart=/usr/bin/python3 /opt/lingva-tts-api/server.py --host 127.0.0.1 --port 3000
Environment=PYTHONUNBUFFERED=1
DynamicUser=yes
Restart=on-failure
RestartSec=3
TimeoutStopSec=5
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
CapabilityBoundingSet=
MemoryLimit=96M
CPUQuota=75%
TasksMax=32
LimitNOFILE=256

[Install]
WantedBy=multi-user.target
```

启用服务：

```bash
systemctl daemon-reload
systemctl enable --now lingva-tts-api.service
systemctl status lingva-tts-api.service --no-pager -l
```

确认仅监听本机：

```bash
ss -lntp | grep ':3000'
```

正确结果应包含：

```text
127.0.0.1:3000
```

不要直接监听 `0.0.0.0:3000`。

## 8. 本机 API 测试

健康检查：

```bash
curl http://127.0.0.1:3000/health
```

英文 TTS：

```bash
curl -sS \
  -o /tmp/tts-test.json \
  -w 'HTTP %{http_code} bytes %{size_download}\n' \
  http://127.0.0.1:3000/api/v1/audio/en/Normal
```

检查返回的音频字节数：

```bash
python3 -c "import json; d=json.load(open('/tmp/tts-test.json')); print(len(d.get('audio', [])), d.get('error'))"
```

成功时：

- HTTP 状态为 `200`
- `audio` 数组长度大于 `0`
- `error` 为 `None`

## 9. 配置 Caddy

先备份：

```bash
cp -a /etc/caddy/Caddyfile /etc/caddy/Caddyfile.bak-before-lingva-20260720
```

在现有 `/etc/caddy/Caddyfile` 末尾增加：

```caddyfile
lingva.haudios.com {
    handle_path /pot-67a8cf0de7c5655699e1acc6ddd5e703/* {
        reverse_proxy 127.0.0.1:3000
    }

    respond "Not Found" 404
}
```

这段配置的作用：

- 只有带专用路径的请求会进入 TTS API
- Caddy 会去掉 `/pot-...` 前缀再转发
- 其他路径返回 `404`
- Caddy 自动签发和续期 HTTPS 证书
- 后端 `3000` 端口不会暴露到公网

格式化、检查并重新加载：

```bash
caddy fmt --overwrite /etc/caddy/Caddyfile
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
systemctl reload caddy
systemctl is-active caddy
```

查看证书签发日志：

```bash
journalctl -u caddy --since "10 minutes ago" --no-pager
```

成功日志应包含：

```text
certificate obtained successfully
```

## 10. 公网验证

健康检查：

```bash
curl -sS \
  https://lingva.haudios.com/pot-67a8cf0de7c5655699e1acc6ddd5e703/health
```

英文 TTS：

```bash
curl -sS \
  -o /tmp/tts-public.json \
  -w 'HTTPS %{http_code} time %{time_total}s bytes %{size_download}\n' \
  https://lingva.haudios.com/pot-67a8cf0de7c5655699e1acc6ddd5e703/api/v1/audio/en/Normal
```

中文 TTS：

```text
https://lingva.haudios.com/pot-67a8cf0de7c5655699e1acc6ddd5e703/api/v1/audio/zh/%E4%BD%A0%E5%A5%BD
```

本次最终验证结果：

```text
英文：HTTP 200
中文：HTTP 200
英文音频：7680 字节
HTTPS 总耗时：约 0.58 秒
```

验证无专用路径的请求被阻止：

```bash
curl -o /dev/null -w '%{http_code}\n' \
  https://lingva.haudios.com/api/v1/audio/en/Normal
```

应返回：

```text
404
```

## 11. Pot 配置

在 Pot 的 Lingva TTS 配置中填写：

```text
配置名称：我的 Lingva TTS
请求地址：https://lingva.haudios.com/pot-67a8cf0de7c5655699e1acc6ddd5e703
```

注意：

- 请求地址末尾不要加 `/`
- 不要手动添加 `/api/v1/audio`
- 不要填写服务器 IP
- 不要填写原公共地址 `https://lingva.pot-app.com`

如果报错内容里仍出现：

```text
https://lingva.pot-app.com
```

说明 Pot 仍在使用旧配置。删除或停用旧配置，从系统托盘彻底退出 Pot 后重新打开。

## 12. API 格式

### 请求

```http
GET /api/v1/audio/{lang}/{url_encoded_text}
```

示例：

```http
GET /api/v1/audio/en/Normal
```

### 成功响应

```json
{
  "audio": [255, 251, 144, 100]
}
```

实际 `audio` 数组包含完整 MP3 音频字节。

### 错误响应

```json
{
  "error": "错误说明"
}
```

常见状态码：

| 状态码 | 含义 |
|---|---|
| 200 | 合成成功 |
| 400 | 语言代码或文本无效 |
| 404 | 路径不正确 |
| 413 | 文本超过 1200 字符 |
| 502 | 服务器无法连接 Google TTS |
| 503 | 同时请求过多 |

## 13. 日常维护

查看状态：

```bash
systemctl status lingva-tts-api.service --no-pager -l
```

重启：

```bash
systemctl restart lingva-tts-api.service
```

查看日志：

```bash
journalctl -u lingva-tts-api.service -n 100 --no-pager
```

实时查看日志：

```bash
journalctl -u lingva-tts-api.service -f
```

查看资源：

```bash
systemctl status lingva-tts-api.service
free -h
```

查看 Caddy：

```bash
systemctl status caddy --no-pager -l
journalctl -u caddy -n 100 --no-pager
```

## 14. 故障排查

### 14.1 `unexpected EOF during handshake`

检查：

```powershell
Resolve-DnsName lingva.haudios.com -Type A
Resolve-DnsName lingva.haudios.com -Type AAAA
ipconfig /flushdns
```

确认：

- A 指向 `47.76.161.183`
- 没有不可达的 AAAA
- Pot 使用新的 HTTPS 请求地址

### 14.2 `Connection refused`

检查服务：

```bash
systemctl status lingva-tts-api.service
ss -lntp | grep ':3000'
```

尝试：

```bash
systemctl restart lingva-tts-api.service
```

### 14.3 返回 502

测试服务器到 Google：

```bash
curl -sS \
  --max-time 20 \
  --get https://translate.googleapis.com/translate_tts \
  --data-urlencode 'ie=UTF-8' \
  --data-urlencode 'client=gtx' \
  --data-urlencode 'tl=en' \
  --data-urlencode 'q=Normal' \
  -o /tmp/google-tts.mp3

wc -c /tmp/google-tts.mp3
```

如果文件大小为 `0` 或请求失败，说明服务器到 Google 的网络出现问题。

### 14.4 返回 404

确认请求地址包含完整专用路径：

```text
/pot-67a8cf0de7c5655699e1acc6ddd5e703
```

Pot 中只填写基础地址，不填写 `/api/v1/audio`。

### 14.5 请求仍需约 20 秒

服务器的 Python 3.6 网络库可能先尝试不可达的 Google IPv6。当前版本已经改用系统 `/usr/bin/curl`，正常延迟约为 0.5 秒。

确认服务器上的代码包含：

```python
GOOGLE_TTS_URL = "https://translate.googleapis.com/translate_tts"
```

并且 `fetch_audio()` 使用：

```python
subprocess.run(...)
```

## 15. 修改专用路径

当前专用路径：

```text
pot-67a8cf0de7c5655699e1acc6ddd5e703
```

它可以降低 API 被随意扫描或滥用的概率，但不属于真正的身份认证。

如需更换：

1. 生成新随机值：

   ```bash
   openssl rand -hex 16
   ```

2. 修改 `/etc/caddy/Caddyfile` 中的路径。
3. 验证并重新加载 Caddy。
4. 将 Pot 的请求地址同步改成新路径。

```bash
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
systemctl reload caddy
```

## 16. 回滚

本次 Caddy 原配置备份：

```text
/etc/caddy/Caddyfile.bak-before-lingva-20260720
```

如需彻底移除本服务：

```bash
systemctl disable --now lingva-tts-api.service
rm -f /etc/systemd/system/lingva-tts-api.service
systemctl daemon-reload
rm -rf -- /opt/lingva-tts-api
```

恢复 Caddy 前先确认备份文件存在：

```bash
ls -l /etc/caddy/Caddyfile.bak-before-lingva-20260720
```

确认后恢复：

```bash
cp -a /etc/caddy/Caddyfile.bak-before-lingva-20260720 /etc/caddy/Caddyfile
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
systemctl reload caddy
```

这些操作只在确定要移除服务时执行。

## 17. 安全建议

1. 不要在文档或脚本中保存服务器密码。
2. 当前专用路径不要公开分享。
3. 使用 SSH 密钥登录服务器。
4. 修改已经在聊天或其他地方暴露过的 root 密码。
5. 确认 SSH 密钥可用后，关闭 root 密码登录。
6. 定期查看 SSH 登录失败记录。
7. 不要把后端 `3000` 端口开放到公网。
8. 定期安装系统安全更新。

本次连接时服务器已经记录到大量 root 登录失败尝试，应尽快加强 SSH 登录安全。

## 18. 本次实际执行记录

- 检查了服务器系统、内存、磁盘、端口和已有服务。
- 确认 Docker、Caddy 和 Google 网络访问正常。
- 尝试运行官方 Lingva Docker 镜像。
- 因服务器内存过低，停止并删除了官方容器和镜像。
- 官方容器测试期间 `rustdesk-hbbs` 自动重启过一次，随后恢复运行。
- 创建轻量 Python TTS API。
- 配置 systemd 开机自启和资源限制。
- 配置 Caddy 专用 HTTPS 路径。
- 成功签发 `lingva.haudios.com` HTTPS 证书。
- 验证英文、中文音频请求均成功。
- 验证无专用路径的请求返回 `404`。
- 最终 API 常驻内存约 `11 MiB`。
- Caddy、RustDesk 和 TTS API 最终均处于运行状态。