跳转至

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.1localhost 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

cd $HOME/GolandProjects/tfrsmanager
make local-debug

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-debug target 默认 user-service 监听 :8090(由 Makefile USER_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_grantscope=a2a:invoke),让 SDK 可直接走 POST /api/v1/oauth/tokenclient_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.mdtfrs-auth L2 本地 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.mddocs/design/data-definition-conventions.md 中正式记录。5 个接口 100% 符合该标准,client SDK 必须按此 deserialize:

  1. 响应 envelope(无例外)

    { "code": 200, "message": "success", "data": <T> }
    
    错误响应同结构,code 为 HTTP 语义码(400/401/403/404/500/...),data 可能为 ErrorResponse 详情或 null。

  2. 分页结构(扁平,14 接口已统一整改)

    { "code": 200, "message": "success",
      "data": { "total": 100, "page": 1, "pageSize": 20, "items": [...] } }
    

  3. 字段命名:camelCase 全站统一(pageSize 不是 page_sizeaccessToken 不是 access_tokensocketBaseURL 不是 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>>itemsdata.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_keyaccess_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.goseedK8sCluster 中硬编码。如果 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.ParseDuration60s / 1h30m 等)。生产环境不配置 JWT_TTL 时回落默认 24h。