0
0
0

API 契约与字段核对

2026-09-13
2026-09-13
文章摘要
|

来源:intranet-tunnel/docs/mobile-api-contract.md(整篇)(原文 17270 字符)

移动端 App —— 服务端接口契约(v1.0.6 起)

本文件是服务端与移动端之间的冻结契约。两端按本文件并行开发, 任何一方要改字段,必须同时改这里并通知另一方。

契约依据是 docs/mobile-phase1-discovery.md(阶段一现状识别,基于源码通读)。 本文只描述接口长什么样,不重复现状分析的推理过程。

实现状态标记

  • ✅ 已实现(既有接口,直接可用)
  • 🆕 本期实现(服务端已按本契约定稿并实现)
  • ⏳ 规划中(已定契约但未实现,App 端不要提前对接
  • ❌ 本期不做

当前状态:已上线(服务端 v1.0.6)

  • 云上 /healthz 返回 "version":"1.0.6",且 .envMOBILE_ENABLED=true
  • §3.3、§4、§5 的接口全部可用,可以直接联调。
  • v1.0.6 新增 §3.7 两步验证(2FA)第二阶段,并把 §6.1 从「规划中」变为已实现。

⚠️ 对 App 是必须适配的变更:账号开启 2FA 后,/api/mobile/auth/login 返回的是质询而不是令牌组,App 若不识别 mfa_required 会把质询当成会话, 表现为「登录看起来成功、进去却是未登录」。详见 §3.7。

  • 验证情况:服务端侧 40 项断言通过(含独立实现的 TOTP 验证码、票据一次性、

验证码重放拒绝、内嵌前端确为新版)。移动端适配后需自行补一轮联调。

⚠️ 联调注意:云上 AUTH_CAPTCHA_ENABLED=true登录必须带图形验证码 (先调 GET /api/auth/captcha?theme=dark,再回传 captcha_id + captcha_code)。 若联调脚本拿不到验证码,可按 docs/AGENTS-ARCHIVE.md 里的流程临时关闭它 (改 .env + --force-recreate,测完必须改回并校验 .env 字节一致)。


一、总开关与地址

1.1 移动端总开关

# .env
MOBILE_ENABLED=false        # 默认关闭
  • 关闭时,所有 /api/mobile/* 返回 404 {"error":"移动端接口未启用(需 MOBILE_ENABLED=true)"}
  • 用 404 而不是 403:对未授权的外部探测不暴露"这里有个被关掉的功能";

但响应文案对运维者是明确的。

  • **既有 /api/* 接口不受此开关影响**,Web 端行为一字不改。
  • 改这个值需要 recreate 容器restart 不重读 env_file):
  sed -i 's/^MOBILE_ENABLED=.*/MOBILE_ENABLED=true/' .env
  docker compose up -d --force-recreate tunnel-server

1.2 服务端地址

App 不硬编码服务端地址,首次启动由用户填写,例如:

https://tunnel.sushike.cloud
  • 所有接口路径都以 /api 开头(没有版本号前缀)。
  • 必须 HTTPS:服务端开启了 ForceHTTPS,HTTP 请求会被重定向。

1.3 前置条件(运维侧,已在云上确认满足)

要求云上现状
PANEL_OUTSIDE_ACCESS必须为 true,否则公网来源调用管理 API 直接 403true
API_ALLOWED_CIDRS留空 = 不限制来源;若收紧了必须把 App 出口 IP 加入✅ 空

判据:从公网无令牌访问 /api/overview,应返回 401(缺令牌)而不是 403(被来源策略挡)。 401 说明链路是通的,只差认证。


二、通用约定

2.1 认证

Authorization: Bearer <access_token>
  • 访问令牌 expires_in = 3600(1 小时,由 JWT_EXPIRE 决定)。
  • 刷新令牌 expires_in = 604800(7 天,由 JWT_REFRESH_EXPIRE 决定)。

2.2 错误响应(全项目统一,只有一处例外)

{ "error": "人类可读的说明" }

语义靠 HTTP 状态码,响应体里没有 code 字段:

状态码含义App 应该做什么
400参数错误(含未知字段)提示具体问题,不要重试
401未认证 / 令牌过期 / 凭据错误先尝试刷新令牌,失败则回到登录页
403被来源策略或权限拒绝提示"无权限",不要重试
404资源不存在 / 移动端接口未启用按文案提示
409冲突(如域名已存在)提示具体冲突
429触发限流(含 Retry-After 头)Retry-After 退避
500服务端错误提示稍后重试

⚠️ 唯一的例外POST /api/auth/sms/send/api/auth/email/send 的失败分支返回 {"success":false,"error":"..."}(HTTP 仍是 4xx)。App 解析这两个接口时要兼容。

2.3 列表与分页

既有接口(/api/clients/api/tunnels 等)没有分页,返回:

{ "data": [ ... ], "total": 3 }

**本期新增的 /api/mobile/* 列表接口一律带分页**,响应多两个字段:

{ "data": [ ... ], "total": 137, "page": 1, "page_size": 20 }
  • page1 开始;page_size 默认 20,上限 100(超出按 100 处理)。
  • total过滤后的总数,用于算"还有多少页"。

2.4 时间格式

全部为 RFC3339 UTC,例如 2026-09-12T15:48:41Z。 App 负责换算成本地时区展示。

2.5 限流

API_RATE_LIMIT=300 次/分钟/IP(令牌桶)。

  • 建议:列表页下拉刷新,不要常驻轮询;单页轮询间隔 ≥ 10 秒。
  • 触发后返回 429 并带 Retry-After(秒)。

2.6 ⚠️ 一个必须知道的硬约束

现有写接口(POST/PUT)解析请求体时启用了 DisallowUnknownFields多传一个字段就整请求 400

因此:App 不能向既有接口附加任何自定义字段(例如给 /api/tunnels 加个 app_version)。 需要额外字段时,走 /api/mobile/* 的同名接口(本契约已按此原则设计)。


三、认证接口

3.1 ✅ 登录能力声明(首屏必调)

GET /api/auth/config

无需令牌。返回当前服务端启用了哪些登录方式,App 据此动态显示登录 Tab

{
  "password_login": true,
  "sms_login": false,
  "captcha_enabled": true,
  "captcha_sms_required": true,
  "sms_provider": "",
  "sms_mock": true,
  "bound_phone_count": 0,
  "sms_code_length": 6,
  "sms_interval": 60,
  "email_login": false,
  "email_configured": false,
  "email_code_length": 6,
  "email_interval": 60,
  "bound_email_count": 0,
  "two_factor_required": false
}
  • two_factor_requiredv1.0.6 新增):账号是否开启了两步验证。

App 可以据此提前把验证码输入准备好(例如在口令框下方提示一句)。 ⚠️ 但它只是提示,不是判据——真正的分支必须看登录响应里的 mfa_required (两者之间可能隔着一次配置变更)。

3.2 ✅ 图形验证码

GET /api/auth/captcha?theme=dark
{ "captcha_id": "...", "image_base64": "iVBORw0...", "image_type": "image/png", "expires_in": 180 }
  • themelight | dark服务端已支持深色配色,App 暗色模式直接传 dark
  • 提交登录时回传 captcha_id + captcha_code大小写不敏感)。

3.3 🆕 口令登录(移动端专用入口)

POST /api/mobile/auth/login

请求(与 /api/auth/login 完全相同):

{
  "username": "admin",
  "password": "******",
  "captcha_id": "可选,captcha_enabled=true 时必填",
  "captcha_code": "可选,同上"
}

响应 200

{
  "token": "<access_token>",
  "refresh_token": "<refresh_token>",
  "expires_at": "2026-09-12T10:00:00Z",
  "expires_in": 3600,
  "user": {
    "id": 1,
    "username": "admin",
    "role": "admin",
    "phone": "138****8000",
    "phone_verified": true,
    "email": "<邮箱已脱敏>",
    "email_verified": true,
    "last_login_at": "2026-09-11T08:00:00Z",
    "last_login_ip": "1.2.3.4"
  }
}

⚠️ 字段名是 token,不是 access_token —— 这是最容易写错的一处。 刷新接口返回的也是同名结构。

为什么另开一个 /api/mobile/auth/login 而不是直接用 /api/auth/login: 两人两端行为当前完全一致,但未来移动端需要 2FA 两阶段登录时(见 §6.1), 需要在这个入口上增加 mfa_required 分支;而 /api/auth/login 的响应结构 已被 Web 端依赖,不能改。提前分开,是为了那时不必破坏 Web 契约。

3.4 ✅ 刷新令牌

POST /api/auth/refresh
{"refresh_token": "<refresh_token>"}

返回全新的令牌组token + refresh_token 都换)。

  • 服务端会重新查库确认账号仍存在。
  • ⚠️ 没有轮换检测:旧 refresh token 在过期前仍然可用。

App 侧要做刷新串行化(多个 401 同时到达时只发一次刷新请求)。

3.5 ✅ 短信 / 邮箱验证码登录

POST /api/auth/sms/send    {"phone":"13800138000"}          → 发送
POST /api/auth/sms/login   {"phone":"...","code":"123456"}  → 登录

POST /api/auth/email/send  {"email":"<邮箱已脱敏>"}
POST /api/auth/email/login {"email":"...","code":"123456"}
  • 登录成功返回结构与 §3.3 一致。
  • 校验顺序是先验码、再查账号(避免枚举哪些手机号已绑定)。
  • 码正确但未绑定账号 → 401「该手机号未绑定任何账号」。
  • sms_mock=true 时响应会带明文 code(联调用,生产应为 false)。

3.6 ✅ 当前账号

GET /api/auth/me

比登录响应多 phone / phone_verified / email / email_verified / last_login_ip

3.7 🆕 两步验证(2FA)· v1.0.6

这是对 App 必须适配的变更。 服务端过去存在一个既有缺陷:设置页能把 2FA 绑上并置为「已启用」,但登录流程里没有任何校验分支——也就是「开了 2FA 实际没有防护效果」。v1.0.6 把口令登录改成两阶段,缺陷已修复。

第一阶段:仍然是 §3.3 的登录入口,但账号开启 2FA 时响应不是令牌组:

POST /api/mobile/auth/login
{ "username": "admin", "password": "...", "captcha_id": "...", "captcha_code": "..." }
{
  "mfa_required": true,
  "mfa_ticket": "kQ8s...(不可猜测的随机串)",
  "expires_in": 120,
  "hint": "请输入认证器 App 中显示的 6 位验证码"
}

⚠️ 此时没有任何令牌(没有 token / refresh_token)。App 必须凭 mfa_required 切到验证码输入界面,不要把该对象当作会话存起来

第二阶段

POST /api/mobile/auth/2fa/verify
{ "mfa_ticket": "上一步拿到的票据", "code": "123456" }

成功时返回与 §3.3 完全相同的令牌组token / refresh_token / expires_at / expires_in / user),App 按原有逻辑落库即可。

服务端语义(App 需要知道的全部约束)

说明
票据有效期120 秒过期返回 401「验证会话已失效,请重新登录」,需回到第一阶段
票据一次性校验成功后立即作废;重复使用返回 401
错误次数上限5 次/票据超过后票据作废,返回 401「验证码错误次数过多,请重新登录」
验证码重放拒绝同一验证码(同一 30 秒时间步)只能成功一次,返回 401「该验证码已被使用…」
限流与登录同源触发时返回 429 + Retry-After: 30

失败响应一律 401 + {"error":"<中文文案>"},App 直接展示 error 即可 (文案已面向用户,含「请检查设备时间是否准确」这类可操作提示)。

行为边界(有意如此,不是遗漏)

  • 2FA 只加在口令登录上。短信/邮箱验证码登录是独立的持证因子

(需要手机/邮箱),因此不叠加 TOTP——否则等于要求两个验证码。 如需让 2FA 成为唯一路径,应在设置里关闭短信/邮箱登录。

  • 2FA 关闭时,上述第一阶段直接返回令牌组,App 行为与 v1.0.5 完全一致
  • 开关状态由 GET /api/auth/configtwo_factor_required 提前告知(见 §3.1)。

⚠️ 找回方式:2FA 绑定后若丢失认证器,设置页的「关闭 2FA」也需要验证码, 因此只能由运维直接改库/改配置(见 §7 的运维项)。App 不需要实现找回流程。


四、告警中心 🆕(本期核心新增)

背景:服务端原来只有「安全告警的邮件通道」,没有「告警实体」—— 邮件发出即结束,无落库、无已读、无处置状态。 本期新增 alerts 表,把它做成可查询、可标记的中心。 邮件通道继续保留alert.Engine 不动),两者共用事件源、互不替代。

4.1 告警对象

{
  "id": 42,
  "kind": "client_offline",
  "level": "warning",
  "title": "客户端 pc1 掉线",
  "message": "最后心跳 2026-09-12T15:48:41Z,已离线 3 分钟",
  "target_type": "client",
  "target_id": "7",
  "route": "/clients/7",
  "details": { "client_id": "pc1", "last_heartbeat": "2026-09-12T15:48:41Z" },
  "status": "unread",
  "created_at": "2026-09-12T15:51:41Z",
  "read_at": null,
  "resolved_at": null,
  "resolved_by": ""
}
字段说明
kind事件类型,见下表
levelinfo / warning / critical
target_typeclient / tunnel / cert / server / ``(空 = 无具体对象)
target_id关联对象 ID(字符串,便于 App 直接拼路由)
route建议跳转路径(Web 路由),App 可映射到自己的页面
details原始上下文(服务端不保证字段固定,App 只做展示)
statusunread / read / resolved

kind 取值全集

kindlevel 建议触发源本期
bancritical自动封禁某 IP🆕(复用既有事件)
auth_brutecritical认证爆破🆕(复用既有事件)
exceptionwarning服务端 ERROR 日志🆕(复用既有事件)
rate_limitwarning限流触发🆕(复用既有事件)
client_offlinewarning客户端掉线🆕 新增钩子
client_onlineinfo客户端上线🆕 新增钩子
cert_expiringwarning / critical证书剩 30/7/1 天🆕 新增钩子
login_anomalywarning异地登录❌ 本期不做(需先建立历史 IP 基线)

4.2 列表

GET /api/mobile/alerts?level=&kind=&status=&page=&page_size=
  • 全部过滤参数可选;status=unread 是最常用的。
  • 默认按 created_at 倒序(最新的在前)。
  • 响应见 §2.3 的分页格式。

4.3 详情

GET /api/mobile/alerts/{id}

返回 §4.1 的单个对象。不存在 → 404。

4.4 标记已读

PUT /api/mobile/alerts/{id}/read

幂等:已读的再标记仍返回 200。响应返回更新后的对象。

4.5 标记已处置

PUT /api/mobile/alerts/{id}/resolve
  • 会把 status 置为 resolved,同时填 resolved_atresolved_by(当前账号用户名)。
  • 未读的告警直接处置时,read_at 也会一并补上(避免出现"已处置但未读"的怪状态)。

4.6 未读计数(角标用)

GET /api/mobile/alerts/unread-count
{"unread": 3, "critical": 1}
  • 体积小、开销低,适合 App 启动/回前台时刷新角标。
  • critical 是未读里 level=critical 的数量,便于把角标标红。

五、精简接口 🆕

目的:移动端首屏与列表页各一次请求搞定,不必像 Web 那样串多个接口。

5.1 仪表盘

GET /api/mobile/dashboard
{
  "clients": { "total": 3, "online": 1 },
  "tunnels": { "total": 3, "enabled": 3, "active_connections": 1 },
  "traffic": { "today_in": 1048576, "today_out": 8388608, "bytes_in": 957, "bytes_out": 16212, "active_conns": 1 },
  "certs": { "total": 3, "expiring_30d": 1, "expiring_7d": 0, "expired": 0 },
  "alerts": { "unread": 2, "critical": 1, "recent": [ /* 最多 5 条,结构同 §4.1 */ ] },
  "server": { "version": "1.0.6", "uptime": "3h12m", "online": 1 }
}
  • traffic.bytes_in / bytes_out进程启动以来的累计(内存原子计数器),

不是"当前速率",也不是当日增量。

  • traffic.today_in / today_out 来自落库统计(30 秒一个采样点),按 UTC 当日聚合。

⚠️ 两者口径不同,界面上不要当成同一维度的数字并列展示

  • traffic.active_connstunnels.active_connections 是同源数字的两种呈现

(前者按流量口径、后者按隧道口径),都来自内存统计。

⚠️ 字段名以本节为准,不要凭记忆写。 这三处曾与实现不一致(文档写 activerealtime_in/out,且漏了 certs.total),实现一直是对的——按文档写代码会取到 undefined,而接口又是 200,排查时极易误判成"数据没产生"。App 侧目前做了双命名 归一化兜底mobile/src/api/dashboard.js),但服务端不会为旧名字保留别名。

5.2 客户端列表

GET /api/mobile/clients?status=&keyword=&page=&page_size=
{
  "data": [
    {
      "id": 7,
      "client_id": "pc1",
      "name": "书房主机",
      "status": "online",
      "online": true,
      "last_heartbeat": "2026-09-12T15:48:41Z",
      "connected_at": "2026-09-12T15:00:00Z",
      "last_ip": "<公网IP>",
      "version": "1.0.4",
      "hostname": "DESKTOP-XXXX",
      "os": "windows",
      "enabled": true,
      "max_tunnels": 20,
      "tunnel_count": 3,
      "active_conns": 1,
      "pool_size": 4,
      "mux_enabled": true,
      "run_id": "3f9c…",
      "remote_ip": "<公网IP>",
      "bandwidth_limit_kbps": 0,
      "remark": "",
      "created_at": "2026-09-12T08:00:00Z",
      "updated_at": "2026-09-12T15:48:41Z"
    }
  ],
  "total": 3, "page": 1, "page_size": 20
}

字段来源分两类,含义不同别混

来源字段说明
数据库(model.Clientstatuslast_heartbeatconnected_atlast_ipversionhostnameosmax_tunnels持久化值,status 可能滞后
运行期内存onlineactive_connspool_sizemux_enabledrun_idremote_iptunnel_count更实时;判断在线一律用 online,不要用 status

⚠️ 三个字段带 omitempty键可能不存在(不是 null): run_id(离线时)、remote_ip(无会话时)以及 model.Client.LastLoginIP 同类的可选字段。

⚠️ tunnel_count 是服务端为列表额外统计的,不在 model.Client 里。

  • status 过滤取 online / offlinekeyword 匹配 client_idname(模糊)。
  • ⚠️ 不含 tokentoken_hash(服务端从不返回 Token 明文,创建时只显示一次)。

5.3 隧道列表

GET /api/mobile/tunnels?client_id=&type=&status=&keyword=&page=&page_size=
{
  "data": [
    {
      "id": 9,
      "name": "portainer",
      "client_id": "pc1",
      "client_name": "书房主机",
      "client_online": true,
      "type": "https",
      "entry": "https://<服务名>.tunnel.sushike.cloud",
      "local_addr": "<内网IP>",
      "local_port": 9000,
      "custom_domain": "portainer",
      "remote_port": 48081,
      "status": "enabled",
      "enabled": true,
      "bytes_in": 957,
      "bytes_out": 16212,
      "connections": 1,
      "active_conns": 1,
      "bandwidth_limit_kbps": 0,
      "remark": "",
      "created_at": "2026-09-12T08:00:00Z",
      "updated_at": "2026-09-12T09:30:00Z"
    }
  ],
  "total": 3, "page": 1, "page_size": 20
}

⚠️ custom_domainremote_port 是「可能不存在」的字段,不是 null: 两者在实现里都带 omitempty*string / *int),取值为空时整个键都不出现

字段何时出现
custom_domaintypehttp / https 的隧道
remote_porttypetcp 的隧道

因此 App 侧要写 tunnel.remote_port ?? 0(而不是判 === null)。

本响应体与 Web 端 GET /api/tunnels 用的是同一个视图结构tunnelView 内嵌 model.Tunnel),因此这里列出的字段就是全部——服务端不会为移动端裁剪字段。 上表的 updated_at、以及 bytes_in/outconnectionsactive_conns 属于「实时统计」部分,来自内存计数器而非数据库累计。

⚠️ 5.4 展示公网地址必须用 entry,不要用 custom_domain

custom_domain 里存的可能是域名前缀(如 portainer), 真实域名由服务端按 TUNNEL_DOMAIN 补全。

  • custom_domain = "portainer"(声明值,原样存库,便于换服务器时只改一处配置)
  • entry = "https://<服务名>.tunnel.sushike.cloud"可直接点击/复制的完整地址

这是 v1.0.3 修过的一个真实缺陷:页面上显示成 https://portainer, 而实际能访问的是 <服务名>.tunnel.sushike.cloudApp 直接用 entry 即可,不要自己去拼 custom_domain


六、2FA(已实现)与本期不做项

§6.1 原先记在「规划中」,v1.0.6 已实现,保留在此只为说明它与当初规划的差异; 接口细节以 §3.7 为准。不要只读本节就去对接。

6.1 ✅ 2FA 两阶段登录(v1.0.6 已实现,见 §3.7)

原状:服务端具备 TOTP 的密钥生成与校验能力,设置页可以绑定, 但登录流程里没有任何 TOTP 校验分支 —— 也就是「开了 2FA 实际没有防护效果」。 这是既有缺陷,不是移动端引入的;v1.0.6 已修复,接口细节见 §3.7。

与当初规划的唯一差异:原文写「Web 端行为一字不改」,实施时发现做不到且不该做—— 只保护移动端入口的话,拿到口令的人直接调 /api/auth/login 就能绕过 2FA, 缺陷等于只修了一半。因此:

  • 两阶段逻辑放在共用的 handleLogin 里,/api/auth/login

/api/mobile/auth/login 行为完全一致;

  • 管理端登录页同步增加了验证码输入步骤(否则开了 2FA 会把管理员锁在面板外);
  • 两个前缀各挂一个第二阶段入口(/api/auth/2fa/verify/api/mobile/auth/2fa/verify),

复用同一份实现——登录路径一旦出现两份代码,某个入口的安全策略必然悄悄漂移。

未开启 2FA 时,两端行为与 v1.0.5 一字不差(回归断言已覆盖)。

6.2 ❌ 推送通道(本期不做)

push 模块(APNs / FCM / 小米 / 华为 / OPPO / vivo / 荣耀)本期不实现

对应的接口与表也不做

❌ POST /api/mobile/device/register
❌ POST /api/mobile/device/unregister
❌ GET|PUT /api/mobile/notification/preferences
❌ GET /api/mobile/devices
❌ DELETE /api/mobile/devices/{id}

对 App 的影响:本期没有系统级推送通知。 告警的触达方式是「App 内列表 + 未读角标」(靠 §4.6 与下拉刷新)。

6.3 ❌ 其他本期不做

原因
异地登录告警(login_anomaly需要先建立历史 IP 基线,属独立判定逻辑
客户端 CPU/内存实时监控需扩展控制协议 + 客户端改造,跨三端,单独立项
流量配额告警系统里目前只有 bandwidth_limit_kbps(限速),没有总量配额概念
RBAC / 资源归属clients / tunnels 表都没有 user_id,数据层无法表达
多设备会话管理(远程登出)需要新增 user_sessions 表;本期令牌仍无撤销能力

七、对 App 端设计有影响的既有事实

以下是服务端现状,不是缺陷清单,但会直接影响 App 的设计假设:

#事实对 App 的影响
1账号级登录锁定无效auth.max_login_attempts 设置项存在,但登录流程未接入 AttemptTracker不要做"账号被锁定,请稍后再试"的提示——实际不会发生。真实生效的是按 IP 的限流与封禁
22FA 已实现(v1.0.6,见 §3.7):账号开启后口令登录返回质询而非令牌必须处理 mfa_required:不处理就会把质询当会话存起来。未开启 2FA 时行为不变
3既有列表接口无分页若 App 误用了 /api/clients(而非 /api/mobile/clients),数据量大时会一次拉全量
4令牌无撤销机制(jti 未落库)远程登出、改密码后强制下线都做不到;App 侧"退出登录"只能清本地存储
5唯一流式端点是 SSE(GET /api/logs/stream?access_token=令牌走 query 参数(EventSource 无法自定义头)。移动端若要用,注意令牌会进日志
6登录返回字段名是 token见 §3.3 的提醒
7短信/邮箱验证码登录不受 2FA 约束它们本身就是持证因子(见 §3.7 的行为边界);想强制唯一路径就在设置里关掉它们

7.1 运维项:2FA 丢失认证器怎么恢复

设置页的「关闭 2FA」也要求输入验证码,因此丢失认证器后无法从界面上关闭。 恢复只能由运维直接改库(或改配置后重建),步骤记在 docs/AGENTS-ARCHIVE.md 的「两步验证」一节。App 不需要实现找回流程, 只需把服务端返回的错误文案如实展示。


八、契约变更流程

  1. 任何一方要改字段,先改本文件,再改代码。
  2. 只做向后兼容的变更(加字段是安全的;改字段名/删字段必须两端同时升级)。
  3. 服务端实现与本文不符时,以本文为准并在服务端修复;

若本文写错了,两处一起改,并在提交信息里写明原因。

  • 例外(2026-09-13 实际发生):若漂移是「实现比文档更全/更准」,

应当以实现为准改文档——实现已经跑在生产上,改实现会破坏已联调的客户端。 当时有三处属此类(tunnels.activeactive_connectionstraffic.realtime_*bytes_in/bytes_out、漏了 certs.total), App 侧被迫做了双命名归一化兜底。

  1. 改完必须跑字段核对,不要靠肉眼:
   node H:\Works\.recover\check-contract-fields.js

它按「移动端各端点实际返回的结构」(含内嵌的 model.Tunnel/Client/Alert) 抽出全部 JSON tag,与本文件做双向比对: 实现有而文档没写、文档有而实现不存在,两边都报。输出 ALL PASS 才算改完。 ⚠️ 它只看字段名,不看类型与语义——omitempty 造成的「键可能不存在」 仍需人工写进文档(本项目已有 custom_domain/remote_port/run_id/remote_ip 四处)。

  1. 本文对应的服务端版本:v1.0.6/healthzversion 字段可确认)。
  2. 变更记录:
    • v1.0.5:新增 §3.3 / §4 / §5(移动端专用接口 + 告警中心)。
    • v1.0.6:新增 §3.7(2FA 两阶段登录),§6.1 由 ⏳ 改为 ✅,

§3.1 增加 two_factor_required对 App 是必须适配的变更,见文首提示。

  • v1.0.6 修订:按实现校准 §3.1 / §5.1 / §5.2 / §5.3 的字段名与字段集

(详见第 3 条的例外说明),并注明 4 处 omitempty 字段。


来源:intranet-tunnel/README.md## 七、REST API(原文 2188 字符)

七、REST API

全部接口以 /api 为前缀,除登录/刷新外均需 Authorization: Bearer <access_token>

方法路径说明
POST/api/auth/login管理员登录,返回 access + refresh 令牌
POST/api/auth/refresh用刷新令牌换取新令牌
GET/api/auth/me当前登录账号信息
GET/api/overview系统总览聚合数据
GET/api/clients客户端列表(含在线状态与实时连接数)
POST/api/clients创建客户端,一次性返回明文 Token
GET/api/clients/:id客户端详情(:id 支持自增 ID 或 client_id
PUT/api/clients/:id更新名称/备注/启用状态/限制
DELETE/api/clients/:id删除客户端(同时删除其隧道并强制下线)
POST/api/clients/:id/kick强制下线
POST/api/clients/:id/token重置 Token(旧 Token 立即失效)
GET/api/clients/:id/tunnels某客户端的隧道列表
GET/api/tunnels隧道列表(含客户端状态、公网入口、实时流量)
POST/api/tunnels创建隧道映射
GET/api/tunnels/:id隧道详情
PUT/api/tunnels/:id更新隧道配置
DELETE/api/tunnels/:id删除隧道
GET/api/stats实时流量统计与连接数(含窗口汇总)
GET/api/stats/timeline时间序列数据(windowtunnel_id 参数)
GET/healthz健康检查(无需鉴权,供容器与 Nginx 探活)

响应约定:

  • 成功返回资源对象,列表接口返回 {"data": [...], "total": N}
  • 失败返回非 2xx 状态码与 {"error": "可读的中文原因"}

示例:

# 登录
TOKEN=$(curl -s -X POST http://127.0.0.1:47801/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"your_password"}' | jq -r .token)

# 创建客户端(务必保存返回的 token,之后无法再次查看)
curl -s -X POST http://127.0.0.1:47801/api/clients \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"client_id":"office-pc-01","name":"办公网 NAS","max_tunnels":10}'

# 创建 HTTP 隧道(自动热下发到在线客户端)
curl -s -X POST http://127.0.0.1:47801/api/tunnels \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"client_id":"office-pc-01","name":"web","type":"http","local_addr":"127.0.0.1","local_port":8080,"custom_domain":"app.example.com"}'

# 创建 TCP 隧道(remote_port 传 0 表示由服务端从端口池自动分配)
curl -s -X POST http://127.0.0.1:47801/api/tunnels \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"client_id":"office-pc-01","name":"ssh","type":"tcp","local_addr":"127.0.0.1","local_port":22,"remote_port":0}'


来源:intranet-tunnel/docs/mobile-test-checklist.md## 十六、真实接口对接验证(npm run verify-live(原文 1137 字符)

十六、真实接口对接验证(npm run verify-live

这是唯一能回答"App 的解析代码对真实服务端响应是否成立"的检查。 它打真实 HTTP,并且直接 import App 的解析函数utils/alert-flow.jsconfig/route-map.js)去处理那些响应 —— 验证的是"App 会不会正确解析", 而不是"我写的样本对不对"。

为什么需要一个本地实例

脚本会故意连续 3 次用错误口令登录,以让防护器产生一条真实的封禁告警 (这是最容易制造的告警类型)。这会污染登录失败计数, 所以不要对生产环境跑

起实例

$e2e = Join-Path $env:TEMP 'intranet-tunnel-mobile-e2e'

# 1) 编译(输出到临时目录,不动 bin/)
go build -o "$e2e\tunnel-server-e2e.exe" ./server/cmd/server

# 2) 启动(脚本里已配好端口、SQLite 与下面两个关键参数)
powershell -ExecutionPolicy Bypass -File mobile\scripts\start-e2e-server.ps1

# 3) 另开一个终端跑验证
cd mobile && npm run verify-live

启动脚本随仓库维护(mobile/scripts/start-e2e-server.ps1), 不要把它放在会被清理的临时目录里 —— 否则下次要用时工具链就断了。

两个"看起来像缺陷、其实是配置"的坑(都实测踩过)

现象原因
3 次失败登录只返回 2 次 401,告警列表为空GUARD_LOGIN_RATE 太小(默认 20/分钟,令牌桶突发容量更小)。限流检查在失败计数之前,第 3 次直接 429,计数攒不到阈值 → 必须调大它
无论失败多少次都不封禁GUARD_WHITELIST127.0.0.1 → 本机被防护器完全跳过。注意在 Windows PowerShell 里给环境变量赋空串等于删除该变量,所以启动脚本把它指向一个不会来自的网段

连续运行时要注意

上一次验证触发的封禁默认持续 10 秒,紧接着重跑会全程 429。 脚本内置了“检测到 429 就等 12 秒重试”的逻辑并会打印提示, 所以重跑是安全的;看到那行提示不代表服务端有问题。


来源:halo-kb/gitea-同步落地记录.md## 1. 实测校正:portfolio 插件的真实 API(原文 5934 字符)

1. 实测校正:portfolio 插件的真实 API

1.1 真实端点(全部【实测】)

方案文档里猜的路径基本是对的,但绝不能按 REST 风格写 —— 这是本次最容易踩空的一处。

方法路径实测状态码
GET/apis/console.portfolio.muyin.site/v1alpha1/projects/list200
POST/apis/console.portfolio.muyin.site/v1alpha1/projects/create200
POST/apis/console.portfolio.muyin.site/v1alpha1/projects/update200
GET/apis/console.portfolio.muyin.site/v1alpha1/projects/{slug}200 / 404
DELETE/apis/console.portfolio.muyin.site/v1alpha1/projects/{slug}/delete200
GET/apis/console.portfolio.muyin.site/v1alpha1/projects/settings/options200
GET/apis/public.portfolio.muyin.site/v1alpha1/projects/list200(匿名)
GET/apis/public.portfolio.muyin.site/v1alpha1/projects/featured200(匿名)
GET/apis/public.portfolio.muyin.site/v1alpha1/projects/{slug}200(匿名)

⚠️ 三个反直觉点(都实测过)

  1. 没有 POST /projects。写操作一律是 .../projects/create.../projects/update

列表是 .../projects/list。按 REST 写 /projects 会拿到 Spring 的 {"detail":"No static resource apis/.../projects.","status":404} —— 注意这个 404 不是「插件没装」,而是「路径不存在」。

  1. update 按 payload 里的 slug 定位记录,不是路径参数、也不是 metadata.name

实测:body 里只给 slug 就能命中并递增 metadata.version

  1. 匿名访问 console 路径得到 302(跳登录),不是 403。判断「token 有没有生效」看 302/200 即可。

认证Authorization: Bearer <PAT>。本机 HALO_TOKEN用户级环境变量里 (进程级默认没有,需 [System.Environment]::GetEnvironmentVariable('HALO_TOKEN','User'))。

1.2 payload 真实结构(【实测】)

业务字段在 JSON 顶层,没有 spec 嵌套 —— 方案文档的猜测成立apiVersion / kind / metadata 与业务字段平级

实测创建请求(节选)与服务端原样返回

// 请求
{ "apiVersion":"portfolio.muyin.site/v1alpha1", "kind":"Project",
  "metadata":{"name":"project-probe-selftest-deleteme"},
  "title":"PROBE-SELFTEST-DELETEME", "slug":"probe-selftest-deleteme",
  "summary":"probe payload shape test", "content":"# probe\nbody",
  "platform":"github", "type":"open_source",
  "techStacks":["Go","Vue"], "tags":["probe"],
  "repoUrl":"…", "demoUrl":"", "docsUrl":"",
  "sourceProvider":"gitea", "repoOwner":"sushike", "repoName":"probe",
  "priority":5, "featured":true, "status":"published" }
// 服务端返回(HTTP 200)——注意 apiVersion/kind/metadata 与业务字段平级
{"apiVersion":"portfolio.muyin.site/v1alpha1","content":"# probe\nbody",
 "createTime":"2026-09-13 21:01:31","demoUrl":"","docsUrl":"","featured":true,
 "kind":"Project",
 "metadata":{"annotations":{},"creationTimestamp":"2026-09-13T13:01:31.953768520Z",
             "generateName":"project-","labels":{},
             "name":"project-probe-selftest-deleteme","version":0},
 "platform":"github","priority":5,"repoName":"probe","repoOwner":"sushike",
 "repoUrl":"…","slug":"probe-selftest-deleteme","sourceProvider":"gitea",
 "status":"published","summary":"probe payload shape test","tags":["probe"],
 "techStacks":["Go","Vue"],"title":"PROBE-SELFTEST-DELETEME","type":"open_source",
 "updateTime":"2026-09-13 21:01:31"}

1.3 逐项实测结论

结论等级证据
sourceProvider / repoOwner / repoName 确实存在且原样保存回读【实测】上面的往返 JSON 三者都回来了
status 取值是小写 draft / published / archived【实测】+【源码级】"published" 回读即 "published"ProjectStatus@JsonValue getValue() + @JsonCreator fromValue()
只有 published 才出现在 /portfolio 与公开接口【实测】draft 时 /portfolio 计数为 0,改 published 后立刻计入
metadata.name 由插件自动生成project-<slug>【实测】请求里给的名字与返回一致,且 generateName 被写成 project-
createTime / updateTime 由服务端管理,客户端传值被忽略【实测】"2020-01-01 00:00:00" 被丢弃,服务端写回 2026-09-13 21:05:04
createTime / updateTime 格式是 yyyy-MM-dd HH:mm:ss(Asia/Shanghai)【实测】同上;UTC 13:05 → 显示 21:05
cover 允许空串 ""【实测】回读 "cover":"",无报错
content 支持 Markdown,但渲染器不支持表格【实测】见 §1.4
公开接口把 content 原样返回给匿名访客【实测】匿名 .../projects/list 返回体里含完整 content

1.4 陷阱一:Markdown 表格不会被渲染(【实测】)

把元数据写成 Markdown 表格后,前台原样显示成一堆竖线(<u>已实测,见下方证据</u>):

<!-- 用了表格时的真实渲染结果 -->
<div class="prose-custom portfolio-content"><div><p>Intranet tunneling system (Go + Vue + Wails)</p>
<p>| 项 | 值 |
| --- | --- |
| 仓库 | sushike/intranet-tunnel |
...

根因有两条独立证据:

  1. 详情页确实走 Markdown 渲染 —— 引用块 > 被正确渲染成了 <blockquote>

(说明不是「当成纯文本」),但表格没有 → 缺少 GFM 表格扩展。

  1. 解包插件 jar 确认org/commonmark/ext/ 下的类数量为 0**

(jar 里只打包了 commonmark 核心,没有 commonmark-ext-gfm-tables); 详情模板用的是 th:utext="${projectContentHtml}"

处理:脚本的 content 只用核心语法(段落 / 引用 / 无序列表)。 改成列表后实测渲染为 8 个 <li>,竖线 0 处。

1.5 陷阱二:platform / type 必须是选项 value,不是显示名(【实测】)

/portfolio 的平台 / 类型筛选 chip 只由插件设置里的 option 列表渲染。 项目上的 platform / type 若不是其中的 value不会被计入任何 chip —— 接口全部 200、卡片照常显示、只有筛选静默失效

实测对照(同一个项目,只改 platform 取值):

platform 取值卡片上的标签平台 chip 计数
"github"(命中 platformOptionsGitHubGitHub = 1
"Gitea"(显示名,不命中)Gitea(回退显示原值)GitHub/Gitee/… 全部 = 0

type 同理:"Go" 全 chip 为 0;"open_source"开源项目 = 2

这是一个「不报错、只让功能静默失效」的缺陷,所以:

  • 脚本默认 platform="gitea"type="open_source"
  • 每次同步前用 GET .../settings/options 实测拉取选项目录并逐条校验

不命中就打印 ⚠️ 告警(不再静默);

  • 支持用 Gitea topic 逐仓库覆盖:portfolio-platform-<value> / portfolio-type-<value>

1.6 插件设置的读法(附一条本项目已验证过的路径规律)

  • schema / 当前声明GET /apis/api.console.halo.run/v1alpha1/plugins/portfolio/setting
  • 真正生效的值GET /api/v1alpha1/configmaps/plugin-portfolio-configmap

⚠️ 注意是 /api/ 不是 /apis/。配置值是 data.<group> 里的 JSON 字符串{"general":"{\"platformOptions\":[…],\"typeOptions\":[…],…}"}。 (这条路径规律与本项目 minidocs_setup.ps1 里已验证的写法一致。)

另有两条实测到的行为:

  • 插件侧对设置有一层极短的响应式缓存:直接 PUT ConfigMap 后立刻读

settings/options 仍是旧值,约 5 秒后自动刷新不需要重启容器

  • GET .../plugins/portfolio/setting 返回的 formSchema[].valueschema 默认值

不是当前生效值 —— 要判「值到底是多少」必须读 ConfigMap。


支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或者给予支持!

评论