tfrobot-client 本地 UAT 联调指南¶
面向 tfrobot-client(A2C-SMCP 桌面端)工程师的本地端到端联调说明。 TFRSManager 这边不需要新增 UAT 启动命令,复用
make local-debug即可。
1. 协议契约(关键前置认知)¶
线上 TFRobotServer Pod 永远只暴露裸 ASGI(http:// + ws://),TLS 终端在前置反代(腾讯云 CLB → OpenResty → Pod)。
本地通过 Caddy sidecar 在 Pod 前面终端 TLS,与线上 CLB → OpenResty → Pod 完全同构:
线上:Client → CLB(TLS) → OpenResty(HTTP) → uvicorn Pod(裸 ASGI)
本地:Client → Caddy(TLS) ───────────────→ uvicorn (裸 ASGI)
TFRSManager 的 connection-info 接口拼接逻辑保持线上契约不变:
// internal/user/service/digital_employee_service/connection_info_service.go:159
socketBaseURL := "https://" + instance.Cluster.Domain
本地 Cluster.Domain = "127.0.0.1:8443",拼出 https://127.0.0.1:8443,由 Caddy sidecar 终端 TLS。
TFRSManager 业务模型不引入任何 "本地 vs 线上" 的 override 字段。
2. 启动顺序¶
2.1 先起 TFRobotServer + Caddy sidecar¶
以下内容来自 TFRobotServer 仓库的
docs/ops/local-tls-setup.md(已与 server 工程师确认为最终落盘版本,2026-04-24)。 完整原文 + 故障排查请见对方仓库;为方便 client 工程师本地复现 + 避免文档版本漂移,关键内容在此原样嵌入。
2.1.1 准备 cert/key¶
TFRobotServer 不绑定任何 CA 体系,只消费 cert/key 路径。Caddy 只做 os.Open + tls.LoadX509KeyPair,不校验签发者、不校验 CA 链——任何合法 cert 都能用,工程师自选:
方式 1:mkcert(推荐,零配置)
brew install mkcert caddy
mkcert -install # 一次性,装 CA 到系统 trust store;幂等,重跑无害
mkdir -p ~/.tfrobotserver/certs
cd ~/.tfrobotserver/certs
mkcert localhost 127.0.0.1 # 生成 localhost+1.pem / localhost+1-key.pem,重跑会原地覆盖
方式 2:自有 CA(公司内部 CA、office4ai CA、其他签发来源)
把合法 cert/key(签了 127.0.0.1 或 localhost SAN)放到任意路径即可。客户端需 trust 对应 CA。
2.1.2 配 cert 路径(编辑 supervisord.conf 而非 shell export)¶
supervisord.conf 是 gitignored 的 per-user 文件,[supervisord] 段已预设 environment:
TFRS_TLS_CERT=$HOME/.tfrobotserver/certs/localhost+1.pem
TFRS_TLS_KEY=$HOME/.tfrobotserver/certs/localhost+1-key.pem
如用自有 CA / 其他路径,直接编辑 supervisord.conf 的 [supervisord] environment= 行,不需要 shell export(这样 supervisorctl restart 时新值会自动生效)。
2.1.3 TFRobotServer 仓库的 sidecar 文件清单(已落盘)¶
| 路径 | 用途 |
|---|---|
supervisord.d/tls_proxy.conf.example |
Caddy 进程的 supervisord drop-in(拷成 tls_proxy.conf 即生效) |
Caddyfile(仓库根) |
Caddy 反代配置::8443 TLS 终端 → 127.0.0.1:8080 裸 HTTP |
docs/ops/local-tls-setup.md |
完整运维文档(含故障排查表) |
supervisord.d/tls_proxy.conf.example 完整内容:
; 本地 TLS sidecar:镜像线上 OpenResty gateway → Pod 的拓扑
;
; 启用方式:
; 1. 准备 cert/key(参考 docs/ops/local-tls-setup.md)
; 2. cp supervisord.d/tls_proxy.conf.example supervisord.d/tls_proxy.conf
; 3. supervisorctl reread && supervisorctl update
;
; TFRS_TLS_CERT / TFRS_TLS_KEY 来自主 supervisord.conf 的 [supervisord] environment,
; 会自动级联到本程序子进程。自有 CA / 其他路径请修改主配置里的 environment 行。
[program:tfrs_tls_proxy]
command=caddy run --config Caddyfile
directory=/Users/jqq/PycharmProjects/TFRobotServer
autostart=true
autorestart=true
startretries=3
redirect_stderr=true
stdout_logfile=~/.tfrobotserver/logs/tls_proxy.log
stdout_logfile_maxbytes=50MB
stdout_logfile_backups=10
⚠️
directory=/Users/jqq/...需要 client 工程师 clone 仓库后改成自己的绝对路径(per-user 配置,supervisord 风格统一)。
Caddyfile 完整内容(仓库根,已 caddy fmt 规整):
# 本地 TLS 反代:镜像线上 CLB TLS 终端 + OpenResty HTTP proxy 的协议拓扑
# 链路:Client → Caddy (:8443, TLS) → uvicorn (127.0.0.1:8080, 裸 HTTP)
# 对齐线上:Client → 腾讯云 CLB (TLS) → OpenResty Gateway (HTTP) → TFRobotServer Pod (uvicorn)
{
admin off
auto_https off
}
127.0.0.1:8443, localhost:8443 {
tls {$TFRS_TLS_CERT} {$TFRS_TLS_KEY}
reverse_proxy 127.0.0.1:8080 {
header_up Host {http.request.host}
header_up X-Real-IP {http.request.remote.host}
header_up X-Forwarded-Proto https
transport http {
read_timeout 86400s
write_timeout 86400s
dial_timeout 2s
}
}
}
2.1.4 启用 TLS sidecar 并起服务¶
cd $HOME/PycharmProjects/TFRobotServer
# 拷贝 drop-in(首次)
cp supervisord.d/tls_proxy.conf.example supervisord.d/tls_proxy.conf
# 起整套:Caddy + uvicorn + Celery workers + Vector …
# (依赖 PostgreSQL / Redis / RabbitMQ / MinIO,详见 TFRobotServer README)
supervisord -c supervisord.conf
# 如果 supervisord 已在运行,热加载 drop-in(推荐,不打断 uvicorn):
supervisorctl reread && supervisorctl update
2.1.5 验证 TFRobotServer 端就绪¶
# 1) Engine.IO polling(HTTP 反代烟测)
curl -i "https://127.0.0.1:8443/socket.io/?EIO=4&transport=polling"
# 期望:HTTP/2 200,body 以 "0{" 开头
# 2) Healthz
curl https://127.0.0.1:8443/healthz
# 期望:200 {"status":"ok"}
⚠️ 不要用 curl 手工拼 WS upgrade 测
transport=websocket——会返回HTTP/2 400 Invalid websocket upgrade。原因见 §6.4。WebSocket 端到端走 §5.3 Python 客户端验证。
2.1.6 关停 sidecar¶
rm supervisord.d/tls_proxy.conf
supervisorctl reread && supervisorctl update
# uvicorn 仍监听 http://127.0.0.1:8080,不影响其他场景
2.2 再起 TFRSManager¶
local-debug 会按顺序:
1. 起 PostgreSQL + Redis(docker compose)
2. 跑 cmd/seed/(含 ClientPortal UAT seed,见 §3)
3. 起 echo-server :9999、user-service :8090(DE_MODE=mock,避开 TFRobotServer 本地 :8080)、admin-service 127.0.0.1:8081
⚠️ 端口冲突避免:
local-debugtarget 默认 user-service 监听:8090(由 MakefileUSER_PORT_DEBUG ?= 8090控制),原因是本地 TFRobotServer 的 uvicorn 也监听:8080,两进程占同主机同端口会bind: address already in use。tfrobot-client SDK 的 base_url 应填http://localhost:8090。 TFRSManager 其他启动方式(make local-run/make local-run-k8s/make run-user)仍用默认:8080,仅 ClientPortal UAT 场景换端口。
完成后 client 工程师可以拿 §3 的账号登录 user-service,调 connection-info 接口。
3. ClientPortal UAT Seed 数据¶
Seed 主入口:cmd/seed/main.go,ClientPortal 段在 seedClientPortalUAT()(按 cr_name + phone 幂等)。
| 账号 | 手机号 | 密码 | 数字员工 | 用途 |
|---|---|---|---|---|
| ClientPortal 联调账号 | 13800138008 | Test@123456 | 1 个:"本地联调员工" 挂在 local-tfrobotserver 集群 |
场景 A/C/E:单账户登录 + connection-info 真连通 |
| ClientPortal 空员工账号 | 13800138009 | Test@123456 | 0 个 | 场景 D:EmployeeList 空状态 |
| testuser2(已有) | 13900139000 | Test@123456 | 复用现有 | 场景 B:多账户登录(企业 + 个人双账户) |
connection-info 接口对联调账号下的"本地联调员工"返回的字面量(与 TFRobotServer debug profile 严格对齐):
{
"socketBaseURL": "https://127.0.0.1:8443",
"sioPath": "/socket.io/",
"namespace": "tfrobotserver",
"rid": "5f4b3b3b-3b3b-3b3b-3b3",
"robotType": "tfrobot",
"smcpNamespace": "/smcp",
"accessToken": "ac4a30aed11de510a388dd05a0ea26cc41b3ac87c6c1f6ff7c267d823611756c",
"computerName": "本地联调员工",
"routingHeaders": {
"X-TF-Namespace": "tfrobotserver",
"X-TF-RobotId": "5f4b3b3b-3b3b-3b3b-3b3",
"X-TF-RobotType": "tfrobot",
"access_token": "ac4a30aed11de510a388dd05a0ea26cc41b3ac87c6c1f6ff7c267d823611756c"
}
}
3.1 本地 E2E 机器凭证(client_credentials 换发,TFRM-187)¶
seedClientPortalUAT() 还会仅在本地(DB_HOST 为 loopback)为"本地联调员工"幂等铸一条机器身份凭证
(access_tokens 行,principal_type=robot)+ 一条自授 A2A peer_grant(scope=a2a:invoke),让 SDK
可直接走 POST /api/v1/oauth/token 的 client_credentials 真实换发短 JWT(替代已退役的 adminSecret/accessToken)。
凭证在 make local-run / make local-debug 的结尾输出(printAllCredentials)里打印:
本地 E2E 机器凭证(client_credentials → POST /api/v1/oauth/token,TFRM-187):
machineClientId = <robot Account.ID>
machineClientSecret = tfp_...
audience = robot:<robot Account.ID> (自授 org peer_grant,可换 scope: a2a:invoke)
- 复用生产铸造口径(
tokenhash.*+models.AccessToken行形,对齐insertMachineCredential/backfill-machine-identities),幂等可重复跑。 - 前置:user-service 须配
ACCESS_TOKEN_SIGNING_KEY+ACCESS_TOKEN_ISSUER,否则换发 503。 - 消费方示例见
tfrs-foundation-py/docs/e2e.md(tfrs-authL2 本地 E2E)。
4. 8 场景覆盖矩阵¶
| 场景 | 描述 | 本地是否覆盖 | 用什么 |
|---|---|---|---|
| A | 单账户直登 + 名下 ≥2 个 running 员工 | ✓ | testuser(13800138000)名下有 3 个 running + 1 个 init_failed |
| B | 多账户登录返回 AccountSelectionResponse | ✓ | testuser2(13900139000)一组凭据返回企业+个人双账户 |
| C | 混合状态员工 + tfrobot/openclaw 各 ≥1 | ✓ | testuser 名下含 OpenClaw + TFRServer 两类,状态覆盖 running/init_failed;testuser2 企业实例含 stopped |
| D | 空员工列表 | ✓ | clientUATEmptyPhone(13800138009)零员工 |
| E | connection-info 完整字段 + 真连通 SMCP Socket.IO | ✓ | clientUATPhone(13800138008)名下"本地联调员工" → 字面量 100% 对齐 TFRobotServer debug profile |
| F | 402 欠费熔断 | ✓ | make local-debug 下调用 POST /api/v1/mock/organizations/:orgId/subscription-status {"status":"frozen"},再访问 /connection-info 即返回 402。详见 §6.1 |
| G | 403 权限不足 | — (契约测试) | 当前 /connection-info 语义下不返回 403(越权返回 404 信息隐藏,且"list 可见 == 可连"是不变式)。客户端保留 403 typed 分发作为防御路径即可 |
| H | 401 JWT 失效 | ✓ | make local-debug 默认 JWT_TTL=60s,登录后等 60s 任何请求都会 401;可用 JWT_TTL=<duration> make local-debug 覆盖(如 JWT_TTL=5m)。详见 §6.2 |
F 与 H 已在本地 UAT 环境打通,G 在现有语义下不可达,客户端按契约测试(mock 403 响应验证前端文案渲染)即可。
5. 端到端 curl 验证(含真实响应样例)¶
以下 6 段响应均为 2026-04-24 在
make local-debug起的 user-service :8090 上实测输出,可直接对照 client SDK 的 deserialize 类型。
5.0 项目 API 响应标准(client SDK 必须遵守)¶
TFRSManager 所有 user-facing 接口遵循统一契约,在 docs/api/pagination-format-migration.md 与 docs/design/data-definition-conventions.md 中正式记录。5 个接口 100% 符合该标准,client SDK 必须按此 deserialize:
-
响应 envelope(无例外):
错误响应同结构,code为 HTTP 语义码(400/401/403/404/500/...),data可能为 ErrorResponse 详情或 null。 -
分页结构(扁平,14 接口已统一整改):
-
字段命名:camelCase 全站统一(
pageSize不是page_size,accessToken不是access_token,socketBaseURL不是socket_base_url)。 注:routingHeaders物化字段里的access_token是因为下游 server 校验 header 用 snake_case,是契约要求,例外。
client SDK 之前期望"扁平响应(无 envelope)"或"嵌套 user 子对象"是个体偏好,不是 server 不规范。client 必须按上述项目标准适配。
5.1 单账户登录(场景 A,clientUATPhone 13800138008)¶
curl -s -X POST http://localhost:8090/auth/login-by-password \
-H 'Content-Type: application/json' \
-d '{"phone":"13800138008","password":"Test@123456"}' | jq
实测响应:
{
"code": 200,
"message": "success",
"data": {
"token": "eyJhbGci...", // string - JWT Token,client 后续请求带 Bearer
"userId": 9, // number - 用户 ID
"accountId": 16, // number - 当前选中账户 ID
"accountName": "client_uat" // string - 账户名
}
}
Rust DTO 建议(client SDK 适配):
struct ApiEnvelope<T> { code: i32, message: String, data: T }
struct LoginResponse { token: String, user_id: u64, account_id: u64, account_name: String }
// deserialize: resp.json::<ApiEnvelope<LoginResponse>>()?.data
注意:LoginResponse 没有 user: {id, username, displayName, email} 嵌套对象——这是 admin 端独有的设计,user 端目前是扁平 4 字段。如果 client 需要展示 nickname/email/phone,请用 GET /api/v1/auth/me 之类的 me 接口(如有)单独取,或后续在 server 加字段。
5.2 多账户登录(场景 B,testuser2 13900139000)¶
curl -s -X POST http://localhost:8090/auth/login-by-password \
-H 'Content-Type: application/json' \
-d '{"phone":"13900139000","password":"Test@123456"}' | jq
实测响应(同一接口,根据账户数返回不同 data 形态):
{
"code": 200,
"message": "success",
"data": {
"message": "请选择要登录的账户",
"tempToken": "b4342cd6e85195b66220a2d25bbe856d8f05dfb0d7b8e731df3fd0cfd2f8be11", // ← 注意叫 tempToken 不是 sessionToken
"expiresIn": 300, // 秒
"accounts": [
{
"accountId": 2,
"accountName": "testuser2_enterprise",
"nickname": "测试用户2",
"organizationId": 2,
"organizationName": "测试企业",
"organizationType": "enterprise"
},
{
"accountId": 3,
"accountName": "testuser2_personal",
"nickname": "测试用户2",
"organizationId": 1,
"organizationName": "one-person-org-1",
"organizationType": "personal"
}
]
}
}
Rust DTO 建议:用 serde(untagged) enum 区分两种 data 形态:
#[derive(Deserialize)]
#[serde(untagged)]
enum LoginData {
SingleAccount(LoginResponse), // 含 token 字段
MultiAccount(AccountSelectionResponse), // 含 tempToken + accounts
}
struct AccountSelectionResponse {
message: String,
temp_token: String,
expires_in: i32,
accounts: Vec<AccountOption>,
}
struct AccountOption {
account_id: u64, account_name: String, nickname: String,
organization_id: u64, organization_name: String, organization_type: String,
}
5.3 选择账户(场景 B 续)¶
curl -s -X POST http://localhost:8090/auth/select-account \
-H 'Content-Type: application/json' \
-d '{"tempToken":"<上一步的 tempToken>","accountId":2}' | jq
实测响应(与 5.1 单账户登录格式完全一致):
{
"code": 200,
"message": "success",
"data": {
"token": "eyJhbGci...",
"userId": 2,
"accountId": 2,
"accountName": "testuser2_enterprise"
}
}
注意请求字段:{"tempToken": "...", "accountId": 2}——camelCase,且 token 字段叫 tempToken 不是 sessionToken。
5.4 列出名下数字员工(场景 A/C)¶
TOKEN=$(curl -s -X POST http://localhost:8090/auth/login-by-password \
-H 'Content-Type: application/json' \
-d '{"phone":"13800138008","password":"Test@123456"}' | jq -r '.data.token')
curl -s "http://localhost:8090/api/v1/digital-employees" \
-H "Authorization: Bearer $TOKEN" | jq
实测响应(标准 flat pagination + 完整 DigitalEmployeeResponse):
{
"code": 200,
"message": "success",
"data": {
"total": 1,
"page": 1,
"pageSize": 20,
"items": [
{
"id": 11,
"name": "本地联调员工",
"description": "",
"robotId": "5f4b3b3b-3b3b-3b3b-3b3",
"status": "running", // running/stopped/init_failed/...,全集见 §3
"templateId": 1,
"templateDisplayName": "智能客服机器人",
"templateType": "tfrserver", // tfrserver | tfropenclaw
"config": null,
"specInfo": { "instanceSpec": "2c4g" },
"accessURL": "https://127.0.0.1:8443/?namespace=tfrobotserver&rid=...&robotType=tfrobot",
"namespace": "tfrobotserver",
"crName": "seed-client-uat-local-tfrobot",
"clusterId": 2,
"clusterName": "local-tfrobotserver",
"organizationId": 7,
"accountId": 16,
"allowedActions": ["delete","factory_reset","redeploy","stop"],
"createdAt": "2026-04-24T16:14:00.201958+08:00",
"updatedAt": "2026-04-24T16:14:00.20575+08:00"
}
]
}
}
⚠️ client SDK 之前期望
Vec<DigitalEmployeeBrief>直接拿数组——必须改成ApiEnvelope<ListResponse<DigitalEmployeeBrief>>,items在data.items。
5.5 拿 connection-info(场景 E)¶
curl -s "http://localhost:8090/api/v1/digital-employees/11/connection-info" \
-H "Authorization: Bearer $TOKEN" | jq
实测响应:
{
"code": 200,
"message": "success",
"data": {
"socketBaseURL": "https://127.0.0.1:8443",
"sioPath": "/socket.io/",
"namespace": "tfrobotserver",
"rid": "5f4b3b3b-3b3b-3b3b-3b3",
"robotType": "tfrobot",
"smcpNamespace": "/smcp",
"accessToken": "ac4a30aed11de510a388dd05a0ea26cc41b3ac87c6c1f6ff7c267d823611756c",
"computerName": "本地联调员工",
"routingHeaders": {
"X-TF-Namespace": "tfrobotserver",
"X-TF-RobotId": "5f4b3b3b-3b3b-3b3b-3b3",
"X-TF-RobotType": "tfrobot",
"access_token": "ac4a30aed11de510a388dd05a0ea26cc41b3ac87c6c1f6ff7c267d823611756c"
}
}
}
字段全部 camelCase,唯一例外 routingHeaders.access_token 用下划线——因为下游 TFRobotServer 校验 header 用 snake_case,物化契约。
5.6 空员工列表(场景 D,clientUATEmptyPhone 13800138009)¶
TOKEN_EMPTY=$(curl -s -X POST http://localhost:8090/auth/login-by-password \
-H 'Content-Type: application/json' \
-d '{"phone":"13800138009","password":"Test@123456"}' | jq -r '.data.token')
curl -s "http://localhost:8090/api/v1/digital-employees" \
-H "Authorization: Bearer $TOKEN_EMPTY" | jq
实测响应:
{
"code": 200,
"message": "success",
"data": { "total": 0, "page": 1, "pageSize": 20, "items": [] }
}
注意:空列表是 data.items: [] 而不是 data: null,client SDK 解析空状态时不要把 items 当 nullable。
5.3 端到端 Socket.IO 连通(场景 E)¶
Python 版(仅用于"server 端就绪验证",client 工程师本地可用):
import socketio
# ssl_verify=False 的原因见 §6.2
sio = socketio.Client(ssl_verify=False)
sio.connect(
"https://127.0.0.1:8443",
socketio_path="/socket.io",
namespaces=["/smcp"],
auth={"admin_key": "ac4a30aed11de510a388dd05a0ea26cc41b3ac87c6c1f6ff7c267d823611756c"},
headers={
"X-TF-Namespace": "tfrobotserver",
"X-TF-RobotId": "5f4b3b3b-3b3b-3b3b-3b3",
"X-TF-RobotType": "tfrobot",
},
)
print("connected, sid=", sio.get_sid("/smcp"))
sio.disconnect()
Tauri/Rust 客户端:用真实 client 跑场景 A→E,行为和 Python reproducer 一致。Rust 的 rustls/native-tls 通常会读系统 trust store,mkcert -install 后 cert 验证自动通过,无需 accept_invalid_certs=true。
已实测通过的输出(2026-04-24,TFRSManager 工程师本地):
Server 日志(~/.tfrobotserver/logs/tfrobot_api.log):
Socket.IO auth attempt: api_key=ac4a30aed11de510a388...
Socket.IO auth succeeded for 5f4b3b3b-3b3b-3b3b-3b3
[sid=LMCGr7upf7MZfgPRAAAE] [ns=/smcp] Client connected successfully
实际透传到 uvicorn 的 header(Caddy 反代):
via: 1.1 Caddy
x-forwarded-for: 127.0.0.1
x-forwarded-host: 127.0.0.1:8443
x-forwarded-proto: https
x-real-ip: 127.0.0.1
x-tf-namespace: tfrobotserver
x-tf-robotid: 5f4b3b3b-3b3b-3b3b-3b3
x-tf-robottype: tfrobot
后两组 X-TF- header 印证:server 端忽略 routingHeaders,client 全量带上完全安全*。
6. 故障排查¶
6.1 TFRSManager 侧¶
| 现象 | 可能原因 | 排查 |
|---|---|---|
/connection-info 返回 集群域名配置缺失 |
local-tfrobotserver 集群未创建或 Domain 空 | 查 DB:select id, name, domain from k8s_clusters where name = 'local-tfrobotserver',应为 127.0.0.1:8443 |
/connection-info 返回 机器人密钥未生成 |
SystemConfig.adminSecret 未写入 | 查 digital_employee_instances.system_config JSON 是否含 adminSecret。重新跑 make seed-local 会触发 ensureSystemConfig 补齐 |
/connection-info 返回 实例不存在 |
RLS 跨组织访问,或登录账号不是该实例所属组织 | 用 13800138008 登录 → 列出员工 → 用拿到的 id 调 connection-info |
/connection-info 返回 组织订阅已冻结(402) |
freezeChecker 命中 | 联调账号是个人组织,不应触发;如果触发说明 seed 数据脏,重置 PG 重跑 seed |
6.2 TFRobotServer 侧(对方给的关键字)¶
tail -f ~/.tfrobotserver/logs/tfrobot_api.log | \
grep -E "auth (succeeded|failed)|auth attempt|Invalid (API Key|admin token|connection)"
| 客户端现象 | 日志关键字 | 含义 |
|---|---|---|
| success canary | Socket.IO auth succeeded for {rid} |
鉴权通过,应当配合上一行 Socket.IO auth attempt: api_key=... |
| ConnectionRefusedError | Socket.IO auth failed: Invalid API Key |
accessToken 错(检查是否被截断/换行) |
| ConnectionRefusedError | Invalid connection request with no auth data |
客户端没传 auth,检查 SDK 是否注入 auth.admin_key 或 access_token header |
| Socket.IO unknown namespace | /smcp 拼写错 |
client 必须连 /smcp namespace,不是默认 / |
| TLS 握手失败 | curl 报 SSL certificate problem |
mkcert -install 没装到当前用户 trust store;或者 TFRS_TLS_CERT 指错路径 |
Python unable to get local issuer certificate |
Python ssl 用 certifi bundle,不读系统 keychain | reproducer 用 ssl_verify=False,或 export SSL_CERT_FILE=$(mkcert -CAROOT)/rootCA.pem 让 Python 信任 mkcert CA |
6.3 routingHeaders 在本地的特殊行为¶
线上 OpenResty 用 X-TF-Namespace / X-TF-RobotId 做 path-based 路由到 robot pod;
本地 Caddy sidecar 直接反代到单实例 uvicorn,不读这 4 个 header(已 grep server 全仓确认 + 本地实测验证)。
客户端无脑全量带上这 4 个 header 完全安全,server 端会忽略多余 header,不会 reject。 保持线上/本地行为一致,client SDK 不要做条件分支。
6.4 curl 验证 WS upgrade 不可靠(已知陷阱)¶
不要用 curl 手工拼 -H "Upgrade: websocket" 测试 wss——会返回 HTTP/2 400 Invalid websocket upgrade。
根因(与 server 工程师确认):Caddy 与 curl 间 ALPN 协商成 HTTP/2,而 starlette/uvicorn 不支持 WebSocket-over-HTTP/2 (RFC 8441)。即便 --http1.1 强制降级,curl 也只发 HTTP header,不发真 WS 握手帧,没有应用层意义。
这不是 sidecar 拓扑或 cert 配置的问题——只能用真 ws client(Python socketio / Tauri rustls)测。HTTP 反代验证用 §2.1.5 / §5 的 polling 端点即可。
6.5 cert 过期 / 重签¶
mkcert 默认 cert 有效期 825 天。到期前:
cd ~/.tfrobotserver/certs
mkcert localhost 127.0.0.1 # 原地覆盖同名文件
supervisorctl restart tfrs_tls_proxy # supervisord.conf 不用动
6.6 Caddy 启动失败常见原因¶
| 错误信息 | 解决 |
|---|---|
bind: address already in use |
lsof -iTCP:8443 -sTCP:LISTEN 找占用进程杀掉 |
open ...: no such file or directory |
TFRS_TLS_CERT/KEY 路径不存在;检查 supervisord.conf 的 [supervisord] environment 行与 ls ~/.tfrobotserver/certs/ 一致 |
| 权限错误 | mkcert 生成的 key 是 -rw------- owner-only,supervisord 以当前用户运行没问题;如果手工换过 owner,chmod 600 回来 |
7. 维护说明¶
- 本地
Cluster.Domain = 127.0.0.1:8443字面量在cmd/seed/main.go的seedK8sCluster中硬编码。如果 TFRobotServer 改了端口,同步改这一处。 accessToken/rid/namespace字面量在cmd/seed/main.go顶层常量定义(localTFRobotServerAdminSecret等)。如果 TFRobotServer 改了 debug profile,同步改这些常量。
附录 A:F/H 错误路径 UAT 触发方式¶
A.1 F — 402 欠费熔断¶
前置:需要先从 /auth/login-by-password 拿到 access_token 和 organizationId(或者 client_uat 的 orgId,可以在登录响应里读到)。
# 1. 翻转 client_uat 所在组织的订阅到 frozen(orgId 从登录响应 organizationId 读)
curl -s -X POST http://localhost:8090/api/v1/mock/organizations/<orgId>/subscription-status \
-H 'Content-Type: application/json' \
-d '{"status":"frozen"}' | jq
# 2. 再访问 /connection-info,预期 402
curl -s -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer <access_token>" \
http://localhost:8090/api/v1/digital-employees/<id>/connection-info
# → 402
# 3. UAT 完成后恢复
curl -s -X POST http://localhost:8090/api/v1/mock/organizations/<orgId>/subscription-status \
-H 'Content-Type: application/json' \
-d '{"status":"active"}' | jq
402 响应体(客户端可按 data.redirectUrl 实现 Renew 跳转):
{
"code": 40200,
"message": "组织订阅已冻结,请续费后重试",
"data": { "redirectUrl": "https://billing.turingfocus.cn/renew" }
}
redirectUrl 依赖 RENEW_SUBSCRIPTION_URL 环境变量,未设置则 data 为 null(旧契约兼容)。local UAT 可在 .env 里配一个桩 URL。
- 路由仅在
DE_MODE=mock下注册,生产完全不注册;代码位置internal/user/api/handlers/dev_subscription_handler.go - 允许翻转的状态白名单:
active/frozen/grace - 幂等:组织无订阅记录时按首个 enabled 套餐自动建一条虚拟订阅(
payment_order_no=DEV-UAT-...,不进入生命周期)
A.2 H — 401 JWT 失效¶
make local-debug 默认 JWT_TTL=60s,效果:
# 1. 登录拿 token
TOKEN=$(curl -s -X POST http://localhost:8090/auth/login-by-password \
-H 'Content-Type: application/json' \
-d '{"phone":"13800138008","password":"Test@123456"}' | jq -r .data.accessToken)
# 2. 立即访问成功
curl -s -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer $TOKEN" \
http://localhost:8090/api/v1/digital-employees
# → 200
# 3. 等 61 秒后再访问
sleep 61
curl -s -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer $TOKEN" \
http://localhost:8090/api/v1/digital-employees
# → 401
覆盖 TTL:JWT_TTL=10s make local-debug / JWT_TTL=5m ...。格式遵循 Go time.ParseDuration(60s / 1h30m 等)。生产环境不配置 JWT_TTL 时回落默认 24h。