Skip to content

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 表):

字段类型说明
idint64主键(JSON 中以字符串形式返回,如 "14"
namestring模板名称(管理界面展示)
contenttext完整 HTML 文档,即页面全部源码
modestringtenant / public / both
is_defaultbool是否为全局默认模板(PortalConfig.ActiveTemplateID 指向它)
is_systembool是否为系统内置模板(系统模板不可删除)
created_at / updated_attime时间戳

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

示例:

html
<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 管理 → 模板管理」:

Portal管理

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 文件

新建/更新请求体示例:

json
{
  "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)
适用模式tenantpublic
认证接口POST /api/v1/portal/api/loginPOST /api/user-portal/public/login
发送验证码不需要POST /api/user-portal/public/sms-code
请求主体username + passwordphone + sms_code
成功返回session_id / token / user_tokenusername / 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):

json
{
  "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
  • 密码错误 → 401
  • Portal服务已关闭 → 503

6.2 TOTP 两步验证(可选)

若系统开启 TOTP(portal_config.totp_enabled=true),首次校验密码成功会返回:

json
{ "data": { "totp_required": true, "username": "zhangsan" } }

此时需再调用验证接口完成登录:

POST /portal/api/totp/verify
{ "username": "zhangsan", "code": "123456" }

6.3 最小可运行模板(账号密码)

html
<!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_code6 位短信验证码
nas_id否*设备标识
nas_ip否*设备 IP
ip_addr用户 IP
mac_addr用户 MAC

*未带 nas_id/nas_ip 时,若该设备未配置为公共模式,将返回 该设备未开启公共访客模式(403)。务必从重定向 URL 回填。

成功响应:

json
{
  "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 最小可运行模板(手机短信)

html
<!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 下发

开发要点

  1. 重定向 URL 上的 nas_id / nas_ip 是设备侧配置的,模板不要硬编码
  2. 模板必须 new URLSearchParams(window.location.search) 取出并随登录请求带回。
  3. 若设备只支持传 nas_identifier,系统同样识别(解析顺序:nas_idnas_ipnas_identifier)。
  4. 无参数且无法用客户端 IP 推断时,将回退到全局默认模板;若默认模板是租户模板而设备实为公共模式,登录会失败。

九、模式探测与自动登录

9.1 模式探测(先决定展示哪种 UI)

GET /api/user-portal/mode?nas_id=&nas_ip=&nas_identifier=

返回:

json
{
  "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 切换渲染账号密码区或手机短信区。

系统在「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 写入(注意 pathSameSite 需与后端一致)。过期的自动登录记录由系统自动清理(详见《用户手册 → 安全设置》)。

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 的骨架:

html
<!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>

十一、最佳实践

  1. 始终回填 nas_id / nas_ip:模板从 location.search 读取并在登录请求体回传,否则公共模式无法定位策略。
  2. 响应式优先:认证页多在手机上打开,务必 viewport + 弹性布局,输入框宽度 max-width:100%
  3. 品牌统一:用 {{.PortalTitle}} / {{.LogoUrl}} / {{.Copyright}} 注入后台配置,避免硬编码;也可直接写死 CSS 主题色。
  4. 安全
    • 密码框用 type="password" 并加 autocomplete
    • 切勿在前端 console.log 明文密码
    • 错误提示统一走 d.error,不要原样回显后端异常。
  5. 验证码倒计时:短信按钮做 60s 倒计时,配合后端 per-IP 限流,避免用户频繁点击触发 429。
  6. 缓存:Portal 响应已带 no-cache;模板里引用的静态资源(图片/字体)建议带版本号或同样禁用强缓存,便于改版即时生效。
  7. TOTP 兼容:若后端可能开启 TOTP,模板应处理 totp_required 返回,提供第二步验证码输入。
  8. 模板体积:单模板即一个完整 HTML 文档,建议控制在 50KB 内,避免首屏过慢(Portal 多在弱网环境)。

十二、API 速查表

用途方法 & 路径关键字段
渲染 Portal 页GET /portal?nas_id=&nas_ip=
模式探测GET /api/user-portal/modeproduct_mode
账号密码登录POST /api/v1/portal/api/loginusername,password,nas_id,nas_ip
TOTP 验证POST /portal/api/totp/verifyusername,code
发送短信验证码POST /api/user-portal/public/sms-codephone,nas_id,nas_ip
手机短信登录POST /api/user-portal/public/loginphone,sms_code,nas_id,nas_ip
Cookie 自动登录GET /portal/api/auto-loginauto_login
会话状态GET /portal/api/statussession_id,token
登出GET /portal/api/logoutsession_id,token
列出模板(管理)GET /api/v1/portal/templates
新建模板(管理)POST /api/v1/portal/templatesname,mode,content
更新模板(管理)PUT /api/v1/portal/templates/:idcontent
删除模板(管理)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/ 前缀),且请求体包含 usernamepassword

Q:短信验证码一直 429? A:触发了 per-IP 限流(5 次/3 分钟)。前端加倒计时,并确认测试环境未频繁重试。

YunyiStudio 版权所有