Portal 页面模板开发指南
本文档面向二次开发 / 品牌定制场景,详细说明 YunyiRADIUS Portal 认证页(Captive Portal)模板的数据结构、渲染流程、管理接口,以及如何开发同时支持 账号密码登录(租户模式)与 手机短信登录(公共访客模式)的页面模板。
普通管理员配置入口见《用户手册 → Portal 认证配置与对接》的「Portal 模板管理」小节;本文档聚焦模板内容本身的开发。
一、核心概念
Portal 页是终端用户被 NAS/BAS 设备拦截重定向后看到的认证页面。系统通过「网络节点(Node)→ 接入策略(AccessPolicy)→ Portal 模板(PortalTemplate)」三级关联,决定给用户展示哪一套页面。
- 网络节点(Node):公共 / 租户模式的统一承载入口,
mode决定认证模式。 - 接入策略(AccessPolicy):策略上的
portal_template_id指定该场景使用的模板。 - Portal 模板(PortalTemplate):一张存于
portal_template表的记录,其content字段是一段完整的 HTML 文档(含<style>与<script>),由服务端原样渲染返回给浏览器。
模板分三种模式(mode 字段):
| mode | 含义 | 典型登录方式 |
|---|---|---|
tenant | 租户模式 | 账号 + 密码(可选 TOTP) |
public | 公共访客模式 | 手机号 + 短信验证码 |
both | 通用模板 | 由页面自己调用模式探测接口决定展示哪种 UI |
二、模板数据模型
PortalTemplate 结构体字段(对应 portal_template 表):
| 字段 | 类型 | 说明 |
|---|---|---|
id | int64 | 主键(JSON 中以字符串形式返回,如 "14") |
name | string | 模板名称(管理界面展示) |
content | text | 完整 HTML 文档,即页面全部源码 |
mode | string | tenant / public / both |
is_default | bool | 是否为全局默认模板(PortalConfig.ActiveTemplateID 指向它) |
is_system | bool | 是否为系统内置模板(系统模板不可删除) |
created_at / updated_at | time | 时间戳 |
content是一段自包含 HTML:必须包含<!DOCTYPE html>、<head>、<body>以及登录所需的<script>。系统不会为你包裹任何外壳。
服务端渲染与可用变量
当 content 能被 Go html/template 成功解析时(即包含 {{.Xxx}} 语法),服务端会做变量替换;解析失败时则作为纯静态 HTML 原样返回(因此 Vue / 任意前端框架的 {{ }} 语法也不会被破坏)。
可用的服务端变量:
| 变量 | 含义 | 来源 |
|---|---|---|
{{.PortalTitle}} | 页面标题 | portal_config.portal_title,为空时回退为 YUNYIRADIUS 网络认证 |
{{.LogoUrl}} | 品牌 Logo 地址 | portal_config.logo_url |
{{.Copyright}} | 版权文案 | 自动生成 © {year} YUNYIRADIUS |
示例:
<title>{{.PortalTitle}}</title>
<img src="{{.LogoUrl}}" alt="logo">
<footer>{{.Copyright}}</footer>若你的模板不使用 Go 模板语法,上述变量不会生效,请直接写死或使用前端 JS 读取配置接口。
三、模板解析与渲染流程
Portal 页由 GET /portal(及 /portal/)触发,解析顺序如下:
用户访问 GET /portal?nas_id=X&nas_ip=Y
└─ servePortal()
1) 解析设备 → 接入策略 → 策略的 portal_template_id
a. 优先读 URL 查询参数 (?nas_id / ?nas_ip / ?nas_identifier) ← 传统方式
b. 无参数时按 客户端IP → radius_online.framed_ipaddr → NAS 身份推断 ← 新方式
2) 回退到全局默认:PortalConfig.ActiveTemplateID
3) 再回退到内置静态页:public/portal/index.html
└─ renderPortalTemplate(tpl.Content, cfg)
- 能解析为 Go 模板 → 变量替换后返回
- 不能解析 → 原样返回 HTML响应头统一为 Content-Type: text/html; charset=utf-8,并带 Cache-Control: no-cache,确保每次都是最新模板。
关键点:模板选择依赖「设备身份」。传统方式下,NAS 重定向 URL 必须带
nas_id/nas_ip;若你在模板里没把这两个参数带回登录请求体,后端将无法定位所属节点与策略,公共模式会返回「该设备未开启公共访客模式」。详见第六节。
四、创建与管理模板
4.1 管理后台(UI)
进入「Portal 管理 → 模板管理」:


可创建 / 编辑 / 删除模板,将某个模板设为默认,并下载模板 HTML 源码。
4.2 管理 API(需管理员鉴权)
所有接口位于 /api/v1/portal/templates 下(即 s.api 组,需 Bearer Token):
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/v1/portal/templates | 列出全部模板 |
| GET | /api/v1/portal/templates/:id | 获取单个模板(含 content) |
| POST | /api/v1/portal/templates | 新建模板 |
| PUT | /api/v1/portal/templates/:id | 更新模板 |
| DELETE | /api/v1/portal/templates/:id | 删除模板(系统模板 is_system=true 不可删) |
| POST | /api/v1/portal/templates/:id/default | 设为全局默认模板 |
| GET | /api/v1/portal/templates/:id/download | 下载模板 HTML 文件 |
新建/更新请求体示例:
{
"name": "酒店品牌模板",
"mode": "both",
"content": "<!DOCTYPE html><html>...</html>",
"is_default": false
}
id在 JSON 中以字符串返回(如"14"),更新 / 删除时路径参数同样传字符串。这是系统为有符号 64 位 ID 防 JS 精度丢失的有意设计。
4.3 模板绑定优先级(最终生效规则)
接入策略级 portal_template_id (最高优先级)
↓ 未指定
节点默认模板(节点 default_policy_id → 策略 → template_id)
↓ 仍未指定
全局默认 PortalConfig.ActiveTemplateID
↓ 仍未指定
内置静态页 public/portal/index.html即:策略上显式绑定模板 > 全局默认模板 > 内置页。
五、两种登录方式总览
| 维度 | 账号密码登录(tenant) | 手机短信登录(public) |
|---|---|---|
| 适用模式 | tenant | public |
| 认证接口 | POST /api/v1/portal/api/login | POST /api/user-portal/public/login |
| 发送验证码 | 不需要 | POST /api/user-portal/public/sms-code |
| 请求主体 | username + password | phone + sms_code |
| 成功返回 | session_id / token / user_token | username / token / expire_time |
| 限流 | 登录接口 per-IP 限流 | 短信接口 per-IP 限流(5 次/3 分钟) |
⚠️ 注意命名空间差异:租户登录在
/api/v1/portal/api/下,公共登录在/api/user-portal/public/下,二者不是同一组前缀。模板里的fetch地址务必与下表完全一致。
六、账号密码登录(租户模式)
6.1 接口
POST /api/v1/portal/api/login
Content-Type: application/json请求体:
| 字段 | 必填 | 说明 |
|---|---|---|
username | 是 | 租户账号 |
password | 是 | 密码(明文经 HTTPS 传输) |
nas_id | 否* | 重定向携带的设备标识(见 6.4) |
nas_ip | 否* | 设备 IP |
mac_addr | 否 | 用户 MAC(可由 X-Mac 头或 mac 参数传入) |
ip_addr | 否 | 用户 IP(直接访问未走设备重定向时可为空) |
*
nas_id/nas_ip非接口强制,但用于后端定位节点/策略与触发 BAS 下发;走设备重定向时强烈建议回填。
成功响应(HTTP 200):
{
"data": {
"session_id": 182,
"username": "zhangsan",
"ip_addr": "10.0.0.100",
"plan_name": "标准套餐",
"login_at": "2026-07-11T10:00:00Z",
"token": "<cookie 自动登录令牌,仅当 CookieEnabled=true>",
"user_token": "<用户门户 JWT,用于免登跳转用户自助页>",
"totp_required": false
}
}错误响应(error 字段):
用户名密码不能为空→ 400用户不存在或已禁用→ 401密码错误→ 401Portal服务已关闭→ 503
6.2 TOTP 两步验证(可选)
若系统开启 TOTP(portal_config.totp_enabled=true),首次校验密码成功会返回:
{ "data": { "totp_required": true, "username": "zhangsan" } }此时需再调用验证接口完成登录:
POST /portal/api/totp/verify
{ "username": "zhangsan", "code": "123456" }6.3 最小可运行模板(账号密码)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>{{.PortalTitle}}</title>
<style>
body{font-family:system-ui,sans-serif;background:linear-gradient(135deg,#0f172a,#1a2744);
min-height:100vh;display:flex;align-items:center;justify-content:center;margin:0;padding:20px}
.card{background:#fff;border-radius:16px;width:400px;max-width:100%;padding:40px 36px;
box-shadow:0 20px 60px rgba(0,0,0,.3);text-align:center}
h1{font-size:20px;color:#1a3a6e;margin:0 0 4px}
.sub{font-size:13px;color:#8a9bb5;margin:0 0 24px}
.input{width:100%;padding:12px 16px;border:1px solid #dce4ec;border-radius:8px;
font-size:14px;margin-bottom:14px;background:#fafbfd;outline:none;box-sizing:border-box}
.input:focus{border-color:#4dabf7;box-shadow:0 0 0 3px rgba(77,171,247,.1)}
.btn{width:100%;padding:13px;background:#2d9bf0;color:#fff;border:none;border-radius:8px;
font-size:15px;font-weight:600;cursor:pointer;margin-top:8px}
.btn:hover{background:#228be6}
.err{color:#e53e3e;font-size:13px;min-height:18px;margin-bottom:8px}
.foot{font-size:12px;color:#bbb;margin-top:20px}
</style>
</head>
<body>
<div class="card">
<h1>{{.PortalTitle}}</h1>
<p class="sub">请输入账号和密码连接网络</p>
<div class="err" id="err"></div>
<input class="input" type="text" id="username" placeholder="用户名" autocomplete="username">
<input class="input" type="password" id="password" placeholder="密码" autocomplete="current-password">
<button class="btn" onclick="login()">连接网络</button>
<div class="foot">{{.Copyright}}</div>
</div>
<script>
async function login(){
const u = document.getElementById('username').value.trim();
const p = document.getElementById('password').value;
const err = document.getElementById('err');
if(!u || !p){ err.innerText = '请输入用户名和密码'; return; }
// 从设备重定向 URL 取 nas 参数回填(关键)
const q = new URLSearchParams(window.location.search);
const nas_id = q.get('nas_id') || '';
const nas_ip = q.get('nas_ip') || '';
try{
const r = await fetch('/api/v1/portal/api/login', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ username:u, password:p, nas_id, nas_ip })
});
const d = await r.json();
if(d.data && d.data.totp_required){
err.innerText = '请使用 TOTP 应用完成两步验证';
// 此处可跳转/展开 TOTP 输入框,再调 /portal/api/totp/verify
return;
}
if(d.error){ err.innerText = d.error; return; }
// 登录成功:刷新页面,由后端下发会话
location.reload();
}catch(e){ err.innerText = '网络错误,请重试'; }
}
</script>
</body>
</html>七、手机短信登录(公共访客模式)
公共模式无需预置账号:用户输入手机号 → 获取短信验证码 → 提交即由后端创建临时访客账号并放行。
7.1 发送验证码
POST /api/user-portal/public/sms-code
Content-Type: application/json| 字段 | 必填 | 说明 |
|---|---|---|
phone | 是 | 中国大陆手机号(1 开头共 11 位) |
nas_id | 否* | 设备标识 |
nas_ip | 否* | 设备 IP |
成功:{ "message": "验证码已发送至 138****8000" } 失败:请输入正确的手机号(400) / 验证码发送过于频繁,请稍后再试(429,per-IP 限流) / 短信网关未配置,无法发送验证码(503)。
限流:该接口已叠加 per-IP 限流(5 次 / 3 分钟),防止短信轰炸。前端应做 60s 倒计时避免频繁点击。
7.2 登录
POST /api/user-portal/public/login
Content-Type: application/json| 字段 | 必填 | 说明 |
|---|---|---|
phone | 是 | 手机号 |
sms_code | 是 | 6 位短信验证码 |
nas_id | 否* | 设备标识 |
nas_ip | 否* | 设备 IP |
ip_addr | 否 | 用户 IP |
mac_addr | 否 | 用户 MAC |
*未带
nas_id/nas_ip时,若该设备未配置为公共模式,将返回该设备未开启公共访客模式(403)。务必从重定向 URL 回填。
成功响应:
{
"data": {
"username": "pub_2_13800001111",
"phone": "13800001111",
"realname": "访客",
"mode": "public",
"token": "<会话令牌>",
"session_duration_min": 60,
"reauth_daily": false,
"expire_time": "2026-07-11T11:00:00Z"
}
}会话时长 session_duration_min、是否每日重认证 reauth_daily 均来自该设备所属接入策略。
7.3 最小可运行模板(手机短信)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>{{.PortalTitle}}</title>
<style>
body{font-family:system-ui,sans-serif;background:linear-gradient(135deg,#0f172a,#1a2744);
min-height:100vh;display:flex;align-items:center;justify-content:center;margin:0;padding:20px}
.card{background:#fff;border-radius:16px;width:400px;max-width:100%;padding:40px 36px;
box-shadow:0 20px 60px rgba(0,0,0,.3);text-align:center}
h1{font-size:20px;color:#1a3a6e;margin:0 0 4px}
.sub{font-size:13px;color:#8a9bb5;margin:0 0 24px}
.input{width:100%;padding:12px 16px;border:1px solid #dce4ec;border-radius:8px;
font-size:14px;margin-bottom:14px;background:#fafbfd;outline:none;box-sizing:border-box}
.row{display:flex;gap:10px;margin-bottom:14px}
.row .input{flex:1;margin-bottom:0}
.sms-btn{width:120px;padding:12px;background:#eef5ff;color:#4dabf7;border:1px solid #c4dcf9;
border-radius:8px;font-size:12px;cursor:pointer;flex-shrink:0}
.sms-btn:disabled{opacity:.5;cursor:not-allowed}
.btn{width:100%;padding:13px;background:#2d9bf0;color:#fff;border:none;border-radius:8px;
font-size:15px;font-weight:600;cursor:pointer;margin-top:8px}
.btn:hover{background:#228be6}
.err{color:#e53e3e;font-size:13px;min-height:18px;margin-bottom:8px}
.foot{font-size:12px;color:#bbb;margin-top:20px}
</style>
</head>
<body>
<div class="card">
<h1>{{.PortalTitle}}</h1>
<p class="sub">输入手机号获取验证码免费上网</p>
<div class="err" id="err"></div>
<input class="input" type="tel" id="phone" placeholder="手机号" maxlength="11">
<div class="row">
<input class="input" type="text" id="code" placeholder="短信验证码" maxlength="6">
<button class="sms-btn" id="sendBtn" onclick="sendCode()">获取验证码</button>
</div>
<button class="btn" onclick="doLogin()">连接上网</button>
<div class="foot">{{.Copyright}}</div>
</div>
<script>
let countdown = 0;
function params(){ return new URLSearchParams(window.location.search); }
async function sendCode(){
const p = document.getElementById('phone').value.trim();
const err = document.getElementById('err');
if(p.length !== 11 || p[0] !== '1'){ err.innerText = '请输入正确的手机号'; return; }
if(countdown > 0) return;
try{
const r = await fetch('/api/user-portal/public/sms-code', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ phone:p, nas_id:params().get('nas_id')||'', nas_ip:params().get('nas_ip')||'' })
});
const d = await r.json();
if(d.error){ err.innerText = d.error; return; }
countdown = 60;
const btn = document.getElementById('sendBtn');
btn.disabled = true;
const t = setInterval(()=>{
countdown--;
btn.innerText = countdown + 's';
if(countdown <= 0){ clearInterval(t); btn.innerText = '获取验证码'; btn.disabled = false; }
}, 1000);
}catch(e){ err.innerText = '发送失败,请重试'; }
}
async function doLogin(){
const p = document.getElementById('phone').value.trim();
const c = document.getElementById('code').value.trim();
const err = document.getElementById('err');
if(!c){ err.innerText = '请输入验证码'; return; }
try{
const r = await fetch('/api/user-portal/public/login', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ phone:p, sms_code:c, nas_id:params().get('nas_id')||'', nas_ip:params().get('nas_ip')||'' })
});
const d = await r.json();
if(d.error){ err.innerText = d.error; return; }
location.reload();
}catch(e){ err.innerText = '登录失败,请重试'; }
}
</script>
</body>
</html>八、nas_id / nas_ip 传递机制(极易踩坑)
Portal 页「知道该用哪套模板、按哪条策略放行」完全依赖设备身份。传递链路如下:
NAS/BAS 拦截用户 → 302 重定向到
http://{YunyiRADIUS}:1816/portal?nas_id={设备标识}&nas_ip={设备IP}&wlanuserip=...&wlanusermac=...
│
├─ 服务端:用 nas_id/nas_ip 解析 节点 → 策略 → portal_template_id(决定展示哪套模板)
│
└─ 浏览器:模板从 window.location.search 读取 nas_id/nas_ip,
在登录 POST 体里回传 → 后端据此定位节点/策略、触发 BAS 下发开发要点:
- 重定向 URL 上的
nas_id/nas_ip是设备侧配置的,模板不要硬编码。 - 模板必须
new URLSearchParams(window.location.search)取出并随登录请求带回。 - 若设备只支持传
nas_identifier,系统同样识别(解析顺序:nas_id→nas_ip→nas_identifier)。 - 无参数且无法用客户端 IP 推断时,将回退到全局默认模板;若默认模板是租户模板而设备实为公共模式,登录会失败。
九、模式探测与自动登录
9.1 模式探测(先决定展示哪种 UI)
GET /api/user-portal/mode?nas_id=&nas_ip=&nas_identifier=返回:
{
"data": {
"nas_id": "...", "node_id": 2, "network_group_id": 3,
"unknown_nas": false,
"product_mode": "public", // "tenant" 或 "public"
"session_duration_min": 60,
"reauth_daily": false,
"enabled_features": { "sms_login": true, "account_login": true }
}
}mode=both 的通用模板可先调此接口,按 product_mode 切换渲染账号密码区或手机短信区。
9.2 Cookie 自动登录
系统在「Portal 管理 → 自动登录」中可开启 Cookie 免认证(cookie_enabled + cookie_validity 分钟 + cookie_name,默认 portal_token)。流程:
GET /portal/api/auto-login
→ 校验浏览器 portal_token cookie + DB auth_entry 是否过期
→ { "auto_login": true, "username": "...", ... } 或 { "auto_login": false }登录成功响应里若含 token 字段,即为此 cookie 令牌,前端可用 document.cookie 写入(注意 path 与 SameSite 需与后端一致)。过期的自动登录记录由系统自动清理(详见《用户手册 → 安全设置》)。
9.3 状态查询与登出
GET /portal/api/status?session_id=&username=&token=
GET /portal/api/logout?session_id=&username=&token=token 即登录返回的 cookie 令牌(或用户门户 JWT)。登出会标记会话 logout、清除 auth_entry、并写一条 RADIUS 计费停止记录。
十、通用模板示例(both 模式)
一个 mode=both 模板先探测模式再渲染对应 UI 的骨架:
<!DOCTYPE html>
<html lang="zh-CN"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>{{.PortalTitle}}</title>
<style>
body{font-family:system-ui,sans-serif;background:linear-gradient(135deg,#0f172a,#1a2744);
min-height:100vh;display:flex;align-items:center;justify-content:center;margin:0;padding:20px}
.card{background:#fff;border-radius:16px;width:400px;max-width:100%;padding:32px;box-shadow:0 20px 60px rgba(0,0,0,.3)}
.hidden{display:none}
.input{width:100%;padding:12px 16px;border:1px solid #dce4ec;border-radius:8px;margin-bottom:12px;box-sizing:border-box}
.btn{width:100%;padding:13px;background:#2d9bf0;color:#fff;border:none;border-radius:8px;font-weight:600;cursor:pointer}
.tabs{display:flex;gap:8px;margin-bottom:16px}
.tab{flex:1;padding:10px;text-align:center;border-radius:8px;background:#eef5ff;color:#4dabf7;cursor:pointer}
.tab.active{background:#2d9bf0;color:#fff}
</style></head>
<body>
<div class="card">
<h2 style="text-align:center;color:#1a3a6e">{{.PortalTitle}}</h2>
<div class="tabs">
<div class="tab active" id="tabAcc" onclick="switchTab('acc')">账号登录</div>
<div class="tab" id="tabSms" onclick="switchTab('sms')">手机登录</div>
</div>
<div id="accPane">
<input class="input" id="username" placeholder="用户名">
<input class="input" type="password" id="password" placeholder="密码">
<button class="btn" onclick="loginAcc()">连接网络</button>
</div>
<div id="smsPane" class="hidden">
<input class="input" id="phone" placeholder="手机号" maxlength="11">
<input class="input" id="code" placeholder="验证码">
<button class="btn" onclick="sendCode()">获取验证码</button>
<button class="btn" onclick="loginSms()" style="margin-top:8px">连接上网</button>
</div>
<div class="err" id="err" style="color:#e53e3e;font-size:13px;margin-top:10px"></div>
</div>
<script>
const q = new URLSearchParams(location.search);
const nas = { nas_id: q.get('nas_id')||'', nas_ip: q.get('nas_ip')||'' };
function switchTab(t){
document.getElementById('tabAcc').classList.toggle('active', t==='acc');
document.getElementById('tabSms').classList.toggle('active', t==='sms');
document.getElementById('accPane').classList.toggle('hidden', t!=='acc');
document.getElementById('smsPane').classList.toggle('hidden', t!=='sms');
}
// 可选:根据后端模式探测自动选 Tab
fetch('/api/user-portal/mode?nas_id='+encodeURIComponent(nas.nas_id)+'&nas_ip='+encodeURIComponent(nas.nas_ip))
.then(r=>r.json()).then(d=>{ if(d.data&&d.data.product_mode==='public') switchTab('sms'); })
.catch(()=>{});
async function loginAcc(){
const u=username.value.trim(), p=password.value;
const r=await fetch('/api/v1/portal/api/login',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({username:u,password:p,...nas})});
const d=await r.json(); if(d.error){err.innerText=d.error;return;} location.reload();
}
async function sendCode(){
const r=await fetch('/api/user-portal/public/sms-code',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({phone:phone.value.trim(),...nas})});
const d=await r.json(); if(d.error){err.innerText=d.error;} else { err.innerText='验证码已发送'; }
}
async function loginSms(){
const r=await fetch('/api/user-portal/public/login',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({phone:phone.value.trim(),sms_code:code.value.trim(),...nas})});
const d=await r.json(); if(d.error){err.innerText=d.error;return;} location.reload();
}
</script>
</body></html>十一、最佳实践
- 始终回填
nas_id/nas_ip:模板从location.search读取并在登录请求体回传,否则公共模式无法定位策略。 - 响应式优先:认证页多在手机上打开,务必
viewport+ 弹性布局,输入框宽度max-width:100%。 - 品牌统一:用
{{.PortalTitle}}/{{.LogoUrl}}/{{.Copyright}}注入后台配置,避免硬编码;也可直接写死 CSS 主题色。 - 安全:
- 密码框用
type="password"并加autocomplete; - 切勿在前端
console.log明文密码; - 错误提示统一走
d.error,不要原样回显后端异常。
- 密码框用
- 验证码倒计时:短信按钮做 60s 倒计时,配合后端 per-IP 限流,避免用户频繁点击触发 429。
- 缓存:Portal 响应已带
no-cache;模板里引用的静态资源(图片/字体)建议带版本号或同样禁用强缓存,便于改版即时生效。 - TOTP 兼容:若后端可能开启 TOTP,模板应处理
totp_required返回,提供第二步验证码输入。 - 模板体积:单模板即一个完整 HTML 文档,建议控制在 50KB 内,避免首屏过慢(Portal 多在弱网环境)。
十二、API 速查表
| 用途 | 方法 & 路径 | 关键字段 |
|---|---|---|
| 渲染 Portal 页 | GET /portal?nas_id=&nas_ip= | — |
| 模式探测 | GET /api/user-portal/mode | product_mode |
| 账号密码登录 | POST /api/v1/portal/api/login | username,password,nas_id,nas_ip |
| TOTP 验证 | POST /portal/api/totp/verify | username,code |
| 发送短信验证码 | POST /api/user-portal/public/sms-code | phone,nas_id,nas_ip |
| 手机短信登录 | POST /api/user-portal/public/login | phone,sms_code,nas_id,nas_ip |
| Cookie 自动登录 | GET /portal/api/auto-login | auto_login |
| 会话状态 | GET /portal/api/status | session_id,token |
| 登出 | GET /portal/api/logout | session_id,token |
| 列出模板(管理) | GET /api/v1/portal/templates | — |
| 新建模板(管理) | POST /api/v1/portal/templates | name,mode,content |
| 更新模板(管理) | PUT /api/v1/portal/templates/:id | content 等 |
| 删除模板(管理) | DELETE /api/v1/portal/templates/:id | — |
| 设默认模板(管理) | POST /api/v1/portal/templates/:id/default | — |
十三、常见问题
Q:改了模板内容但页面没变? A:Portal 响应带 no-cache,硬刷新(Ctrl+F5)即可;确认改的是「最终生效优先级」上被选中的那套模板(策略绑定 > 全局默认)。
Q:公共模式登录返回「该设备未开启公共访客模式」? A:模板未把 nas_id/nas_ip 带回登录请求体,或该设备所属策略的 product_mode 不是 public。检查设备重定向 URL 是否带参数、策略是否配置为公共模式。
Q:模板里 {{.PortalTitle}} 没被替换? A:说明该 HTML 未被识别为 Go 模板(可能含语法冲突)。系统会原样返回——可改用前端 JS 读取 /api/user-portal/mode 或后台配置接口自行填充。
Q:账号密码模板提交后无反应? A:检查 fetch 地址是否为 /api/v1/portal/api/login(注意 /api/v1/ 前缀),且请求体包含 username 与 password。
Q:短信验证码一直 429? A:触发了 per-IP 限流(5 次/3 分钟)。前端加倒计时,并确认测试环境未频繁重试。