refactor: session-based external auth, dynamic sources, drop netease UID

Auth (external API):
- ID: 16-digit random (non-sequential); Secret: SeaReport- + 32 hex
- POST /auth/session: ID+Secret -> Bearer SESSION (24h, single-session,
  old session invalidated on re-issue, disabled client invalidates)
- clientAuth now validates Bearer SESSION via api_sessions JOIN api_clients

Sources (dynamic, no default, open-source friendly):
- sources table + CRUD route (/api/sources, owner; delete guarded by usage)
- users/user_identities.source ENUM -> VARCHAR, seeded netease/skin
- register/admin create/identity bind: validate against enabled sources
- UI: 来源管理 page; source dropdowns loaded dynamically everywhere
  (register, dashboard identity, users admin, bans), labels dynamic

UID removal:
- game_uid/reporter_game_uid no longer required (db default '', validations
  dropped, frontend fields optional)

Docs: EXTERNAL-API.md session flow + new credential format; API.md updated
Verified: 37 checks (syntax, session logic, source CRUD, UID removal, docs)
This commit is contained in:
2026-08-19 20:08:06 +08:00
parent 6056153f57
commit 65edbaf157
19 changed files with 497 additions and 103 deletions

View File

@@ -11,7 +11,6 @@ const UsersPage = {
const ct = document.getElementById('page-content');
try {
const users = await API.get('/users');
const srcLabel = { netease:'网易', skin:'皮肤站' };
ct.innerHTML = `
<div class="card table-wrap"><table><thead><tr>
<th>#</th><th>用户名</th><th>邮箱</th><th>游戏名</th><th>游戏UID</th><th>来源</th><th>身份</th><th>角色</th><th>状态</th><th>时间</th><th>操作</th>
@@ -19,14 +18,21 @@ const UsersPage = {
<tr>
<td>${u.id}</td><td>${U.esc(u.username)}</td><td>${U.esc(u.email)}</td>
<td>${U.esc(u.game_name)}</td><td>${U.esc(u.game_uid)}</td>
<td>${srcLabel[u.source] || u.source || '网易'}</td>
<td><span class="badge bg-admin" data-src="${U.esc(u.source||'')}">${U.esc(u.source||'-')}</span></td>
<td>${(u.identity_count||0) > 1 ? `<span class="badge bg-processing">${u.identity_count} 个</span>` : (u.identity_count||0) === 1 ? '<span class="badge bg-admin">1</span>' : '<span class="tm ts">-</span>'}</td>
<td>${U.badge(u.role,'role')}</td>
<td>${u.active ? '<span class="badge bg-resolved">启用</span>' : '<span class="badge bg-rejected">禁用</span>'}</td>
<td class="ts">${U.date(u.created_at)}</td>
<td><button class="btn btn-o btn-sm" data-action="UsersPage:editUser:${u.id}:${U.escJs(u.username)}:${U.escJs(u.email)}:${U.escJs(u.game_name)}:${U.escJs(u.game_uid)}:${u.role}:${u.active}:${u.source||'netease'}"><i class="fas fa-edit"></i></button></td>
<td><button class="btn btn-o btn-sm" data-action="UsersPage:editUser:${u.id}:${U.escJs(u.username)}:${U.escJs(u.email)}:${U.escJs(u.game_name)}:${U.escJs(u.game_uid)}:${u.role}:${u.active}:${u.source||''}"><i class="fas fa-edit"></i></button></td>
</tr>`).join('')}</tbody></table></div>
`;
// 来源 code → 动态标签
U.sources().then(() => {
ct.querySelectorAll('[data-src]').forEach(el => {
const hit = (U._sourcesCache||[]).find(s => s.code === el.dataset.src);
if (hit) el.textContent = hit.label;
});
}).catch(()=>{});
} catch (e) { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
},
@@ -44,13 +50,18 @@ const UsersPage = {
U.modal('添加用户', `
<form id="cu-form">
<div class="grid-2"><div class="form-group"><label>用户名 *</label><input id="cu-u" required></div><div class="form-group"><label>密码 *</label><input type="password" id="cu-p" required minlength="6"></div></div>
<div class="grid-2"><div class="form-group"><label>邮箱 *</label><input type="email" id="cu-e" required></div><div class="form-group"><label>来源</label><select id="cu-s"><option value="netease">网易端</option><option value="skin">皮肤站</option></select></div></div>
<div class="grid-2"><div class="form-group"><label>邮箱 *</label><input type="email" id="cu-e" required></div><div class="form-group"><label>来源</label><select id="cu-s"><option value="">不指定</option></select></div></div>
<div class="grid-2"><div class="form-group"><label>角色 *</label><select id="cu-r" onchange="UsersPage.toggleCreateServers()">${Auth.isOwner()?'<option value="owner">服主</option>':''}<option value="admin">管理员</option><option value="player">玩家</option></select></div></div>
<div class="grid-2"><div class="form-group"><label>游戏名 *</label><input id="cu-gn" required></div><div class="form-group"><label>游戏UID *</label><input id="cu-gu" required></div></div>
<div class="grid-2"><div class="form-group"><label>游戏名 *</label><input id="cu-gn" required></div><div class="form-group"><label>游戏UID(可选)</label><input id="cu-gu" placeholder="选填"></div></div>
<div class="form-group hidden" id="cu-servers"><label>管辖子服务器</label><div style="max-height:120px;overflow-y:auto;border:1px solid var(--g200);border-radius:var(--r);padding:8px">${checks||'<span class="tm">暂无可选服务器</span>'}</div></div>
<div class="modal-f"><button type="button" class="btn btn-o" data-action="App:closeModal">取消</button><button type="submit" class="btn btn-p">创建</button></div>
</form>
`);
// 动态加载来源下拉
U.sources().then(list => {
const sel = document.getElementById('cu-s');
if (sel) sel.innerHTML = '<option value="">不指定</option>' + list.map(s => `<option value="${U.esc(s.code)}">${U.esc(s.label)}</option>`).join('');
}).catch(()=>{});
document.getElementById('cu-form').onsubmit = async e => {
e.preventDefault();
const servers = [];
@@ -93,7 +104,7 @@ const UsersPage = {
U.modal('编辑用户', `
<form id="eu-form">
<div class="form-group"><label>用户名</label><input value="${username}" disabled style="background:var(--g100)"></div>
<div class="grid-2"><div class="form-group"><label>邮箱</label><input type="email" id="eu-e" value="${email}"></div><div class="form-group"><label>来源</label><select id="eu-s"><option value="netease" ${source==='netease'?'selected':''}>网易端</option><option value="skin" ${source==='skin'?'selected':''}>皮肤站</option></select></div></div>
<div class="grid-2"><div class="form-group"><label>邮箱</label><input type="email" id="eu-e" value="${email}"></div><div class="form-group"><label>来源</label><select id="eu-s"><option value="${U.esc(source||'')}" selected>${U.esc(source||'不指定')}</option></select></div></div>
<div class="grid-2"><div class="form-group"><label>角色</label><select id="eu-r">${['owner','admin','player'].map(r=>`<option value="${r}" ${r===role?'selected':''}>${U.ROLE[r]}</option>`).join('')}</select></div></div>
<div class="grid-2"><div class="form-group"><label>游戏名</label><input id="eu-gn" value="${gameName}"></div><div class="form-group"><label>游戏UID</label><input id="eu-gu" value="${gameUid}"></div></div>
<div class="form-group"><label>管辖子服务器</label><div style="max-height:120px;overflow-y:auto;border:1px solid var(--g200);border-radius:var(--r);padding:8px">${checks||'<span class="tm">无可用服务器(先在投票→分组管理中添加)</span>'}</div></div>
@@ -102,7 +113,7 @@ const UsersPage = {
<div style="border:1px solid var(--g200);border-radius:var(--r);padding:8px;margin-bottom:6px" id="identities-box">
${identities.length===0?'<span class="tm">暂无身份记录</span>':identities.map(it=>`
<div style="display:flex;align-items:center;gap:6px;padding:4px 0">
<span class="badge bg-${it.source==='netease'?'processing':'admin'}">${it.source==='netease'?'网易端':'皮肤站'}</span>
<span class="badge bg-admin" data-src="${U.esc(it.source)}">${U.esc(it.source||'-')}</span>
<span>${U.esc(it.game_name)}${it.game_uid?' (UID: '+U.esc(it.game_uid)+')':''}</span>
<span class="tm ts">${U.date(it.created_at)}</span>
${identities.length>1?`<button type="button" class="btn btn-d btn-sm" onclick="UsersPage.removeIdentity(${id},${it.id})"><i class="fas fa-unlink"></i></button>`:'<span class="tm ts">主身份</span>'}
@@ -110,10 +121,10 @@ const UsersPage = {
</div>
<div style="display:flex;gap:6px;align-items:center;flex-wrap:nowrap">
<select id="eu-ident-source" style="flex:0 0 92px;width:92px;min-width:92px;padding:6px 8px;border:1px solid var(--g300);border-radius:var(--r);font-size:12px;background:white">
<option value="netease">网易端</option><option value="skin">皮肤站</option>
<option value="">加载中...</option>
</select>
<input id="eu-ident-name" placeholder="游戏名" style="flex:1 1 30%;min-width:0;width:auto;padding:6px 8px;border:1px solid var(--g300);border-radius:var(--r);font-size:12px">
<input id="eu-ident-uid" placeholder="UID(网易必填)" style="flex:1 1 30%;min-width:0;width:auto;padding:6px 8px;border:1px solid var(--g300);border-radius:var(--r);font-size:12px">
<input id="eu-ident-uid" placeholder="UID(可选)" style="flex:1 1 30%;min-width:0;width:auto;padding:6px 8px;border:1px solid var(--g300);border-radius:var(--r);font-size:12px">
<button type="button" class="btn btn-o btn-sm" id="eu-ident-fetch" style="flex:0 0 auto;white-space:nowrap" title="皮肤站用户名查UUID"><i class="fas fa-magnifying-glass"></i></button>
<button type="button" class="btn btn-p btn-sm" style="flex:0 0 auto;white-space:nowrap" onclick="UsersPage.addIdentity(${id})"><i class="fas fa-plus"></i> 绑定</button>
</div>
@@ -139,9 +150,31 @@ const UsersPage = {
try { await API.put('/users/'+id, data); App.closeModal(); this.load(); } catch (ex) { alert(ex.message); }
};
// 动态加载来源(主身份下拉 + 身份绑定下拉), 并替换 data-src 标签
U.sources().then(list => {
const opts = list.map(s => `<option value="${U.esc(s.code)}">${U.esc(s.label)}</option>`).join('');
const sel = document.getElementById('eu-s');
if (sel && opts) {
const cur = sel.value;
sel.innerHTML = opts;
if (cur && list.some(s => s.code === cur)) sel.value = cur;
}
const sel2 = document.getElementById('eu-ident-source');
if (sel2 && opts) {
sel2.innerHTML = opts;
euToggleSource();
}
document.querySelectorAll('#eu-form [data-src]').forEach(el => {
const hit = list.find(s => s.code === el.dataset.src);
if (hit) el.textContent = hit.label;
});
}).catch(()=>{});
// 来源切换: 皮肤站时显示 UUID 查询辅助
const euToggleSource = () => {
const isSkin = document.getElementById('eu-ident-source').value === 'skin';
const sel = document.getElementById('eu-ident-source');
if (!sel) return;
const isSkin = sel.value === 'skin';
document.getElementById('eu-ident-site-group').classList.toggle('hidden', !isSkin);
};
document.getElementById('eu-ident-source').onchange = euToggleSource;