API 契约与字段核对
来源:
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",且.env里MOBILE_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-server1.2 服务端地址
App 不硬编码服务端地址,首次启动由用户填写,例如:
https://tunnel.sushike.cloud- 所有接口路径都以
/api开头(没有版本号前缀)。 - 必须 HTTPS:服务端开启了
ForceHTTPS,HTTP 请求会被重定向。
1.3 前置条件(运维侧,已在云上确认满足)
| 项 | 要求 | 云上现状 |
|---|---|---|
PANEL_OUTSIDE_ACCESS | 必须为 true,否则公网来源调用管理 API 直接 403 | ✅ true |
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 }page从 1 开始;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_required(v1.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 }theme取light|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/config的two_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 | 事件类型,见下表 |
level | info / warning / critical |
target_type | client / tunnel / cert / server / ``(空 = 无具体对象) |
target_id | 关联对象 ID(字符串,便于 App 直接拼路由) |
route | 建议跳转路径(Web 路由),App 可映射到自己的页面 |
details | 原始上下文(服务端不保证字段固定,App 只做展示) |
status | unread / read / resolved |
kind 取值全集:
| kind | level 建议 | 触发源 | 本期 |
|---|---|---|---|
ban | critical | 自动封禁某 IP | 🆕(复用既有事件) |
auth_brute | critical | 认证爆破 | 🆕(复用既有事件) |
exception | warning | 服务端 ERROR 日志 | 🆕(复用既有事件) |
rate_limit | warning | 限流触发 | 🆕(复用既有事件) |
client_offline | warning | 客户端掉线 | 🆕 新增钩子 |
client_online | info | 客户端上线 | 🆕 新增钩子 |
cert_expiring | warning / critical | 证书剩 30/7/1 天 | 🆕 新增钩子 |
login_anomaly | warning | 异地登录 | ❌ 本期不做(需先建立历史 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_at与resolved_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_conns与tunnels.active_connections是同源数字的两种呈现
(前者按流量口径、后者按隧道口径),都来自内存统计。
⚠️ 字段名以本节为准,不要凭记忆写。 这三处曾与实现不一致(文档写
active、realtime_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.Client) | status、last_heartbeat、connected_at、last_ip、version、hostname、os、max_tunnels 等 | 持久化值,status 可能滞后 |
| 运行期内存 | online、active_conns、pool_size、mux_enabled、run_id、remote_ip、tunnel_count | 更实时;判断在线一律用 online,不要用 status |
⚠️ 三个字段带 omitempty,键可能不存在(不是 null): run_id(离线时)、remote_ip(无会话时)以及 model.Client.LastLoginIP 同类的可选字段。
⚠️ tunnel_count 是服务端为列表额外统计的,不在 model.Client 里。
status过滤取online/offline;keyword匹配client_id与name(模糊)。- ⚠️ 不含
token或token_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_domain 与 remote_port 是「可能不存在」的字段,不是 null: 两者在实现里都带 omitempty(*string / *int),取值为空时整个键都不出现。
| 字段 | 何时出现 |
|---|---|
custom_domain | 仅 type 为 http / https 的隧道 |
remote_port | 仅 type 为 tcp 的隧道 |
因此 App 侧要写 tunnel.remote_port ?? 0(而不是判 === null)。
本响应体与 Web 端
GET /api/tunnels用的是同一个视图结构(tunnelView内嵌model.Tunnel),因此这里列出的字段就是全部——服务端不会为移动端裁剪字段。 上表的updated_at、以及bytes_in/out、connections、active_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.cloud。 App 直接用 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 的限流与封禁 |
| 2 | 2FA 已实现(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 不需要实现找回流程, 只需把服务端返回的错误文案如实展示。
八、契约变更流程
- 任何一方要改字段,先改本文件,再改代码。
- 只做向后兼容的变更(加字段是安全的;改字段名/删字段必须两端同时升级)。
- 服务端实现与本文不符时,以本文为准并在服务端修复;
若本文写错了,两处一起改,并在提交信息里写明原因。
- 例外(2026-09-13 实际发生):若漂移是「实现比文档更全/更准」,
应当以实现为准改文档——实现已经跑在生产上,改实现会破坏已联调的客户端。 当时有三处属此类(tunnels.active → active_connections、 traffic.realtime_* → bytes_in/bytes_out、漏了 certs.total), App 侧被迫做了双命名归一化兜底。
- 改完必须跑字段核对,不要靠肉眼:
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 四处)。
- 本文对应的服务端版本:v1.0.6(
/healthz的version字段可确认)。 - 变更记录:
- 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 | 时间序列数据(window、tunnel_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.js、 config/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_WHITELIST 含 127.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/list | 200 |
| POST | /apis/console.portfolio.muyin.site/v1alpha1/projects/create | 200 |
| POST | /apis/console.portfolio.muyin.site/v1alpha1/projects/update | 200 |
| GET | /apis/console.portfolio.muyin.site/v1alpha1/projects/{slug} | 200 / 404 |
| DELETE | /apis/console.portfolio.muyin.site/v1alpha1/projects/{slug}/delete | 200 |
| GET | /apis/console.portfolio.muyin.site/v1alpha1/projects/settings/options | 200 |
| GET | /apis/public.portfolio.muyin.site/v1alpha1/projects/list | 200(匿名) |
| GET | /apis/public.portfolio.muyin.site/v1alpha1/projects/featured | 200(匿名) |
| GET | /apis/public.portfolio.muyin.site/v1alpha1/projects/{slug} | 200(匿名) |
⚠️ 三个反直觉点(都实测过)
- 没有
POST /projects。写操作一律是.../projects/create与.../projects/update,
列表是 .../projects/list。按 REST 写 /projects 会拿到 Spring 的 {"detail":"No static resource apis/.../projects.","status":404} —— 注意这个 404 不是「插件没装」,而是「路径不存在」。
update按 payload 里的slug定位记录,不是路径参数、也不是metadata.name。
实测:body 里只给 slug 就能命中并递增 metadata.version。
- 匿名访问 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 |
...根因有两条独立证据:
- 详情页确实走 Markdown 渲染 —— 引用块
>被正确渲染成了<blockquote>
(说明不是「当成纯文本」),但表格没有 → 缺少 GFM 表格扩展。
- 解包插件 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"(命中 platformOptions) | GitHub | GitHub = 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[].value是schema 默认值,
不是当前生效值 —— 要判「值到底是多少」必须读 ConfigMap。
