feat: admin user management supports multi-identity bind/unbind

- users list: identity_count column (backend subquery)
- user detail: returns identities array
- POST /users/:id/identities, DELETE /users/:id/identities/:identityId
  (owner protection, dup checks, keep >=1, audit logs)
- users page: identity column in table + manage section in edit modal
- API docs updated
This commit is contained in:
2026-08-17 01:34:20 +08:00
parent 4c221ad6f1
commit 25a766591e
3 changed files with 131 additions and 4 deletions

View File

@@ -6,13 +6,17 @@ const { authenticate, requireRole } = require('../middleware/auth');
const router = express.Router(); const router = express.Router();
router.get('/', authenticate, requireRole('owner','admin'), async (req, res) => { router.get('/', authenticate, requireRole('owner','admin'), async (req, res) => {
res.json(await query('SELECT id, username, email, game_name, game_uid, source, role, active, email_verified, created_at FROM users ORDER BY created_at DESC')); const rows = await query(`SELECT u.id, u.username, u.email, u.game_name, u.game_uid, u.source, u.role, u.active, u.email_verified, u.created_at,
(SELECT COUNT(*) FROM user_identities ui WHERE ui.user_id = u.id) as identity_count
FROM users u ORDER BY u.created_at DESC`);
res.json(rows);
}); });
router.get('/:id', authenticate, requireRole('owner','admin'), async (req, res) => { router.get('/:id', authenticate, requireRole('owner','admin'), async (req, res) => {
const u = await getRow('SELECT id, username, email, game_name, game_uid, source, role, active, email_verified, created_at FROM users WHERE id = ?', [req.params.id]); const u = await getRow('SELECT id, username, email, game_name, game_uid, source, role, active, email_verified, created_at FROM users WHERE id = ?', [req.params.id]);
if (!u) return res.status(404).json({ error: '用户不存在' }); if (!u) return res.status(404).json({ error: '用户不存在' });
u.servers = await query('SELECT group_name, server_name FROM user_servers WHERE user_id = ?', [req.params.id]); u.servers = await query('SELECT group_name, server_name FROM user_servers WHERE user_id = ?', [req.params.id]);
u.identities = await query('SELECT id, source, game_name, game_uid, created_at FROM user_identities WHERE user_id = ? ORDER BY id', [req.params.id]);
res.json(u); res.json(u);
}); });
@@ -20,6 +24,58 @@ router.get('/:id/servers', authenticate, requireRole('owner','admin'), async (re
res.json(await query('SELECT group_name, server_name FROM user_servers WHERE user_id = ?', [req.params.id])); res.json(await query('SELECT group_name, server_name FROM user_servers WHERE user_id = ?', [req.params.id]));
}); });
// ---- 管理员: 为用户绑定额外来源身份(多身份管理) ----
router.post('/:id/identities', authenticate, requireRole('owner','admin'), async (req, res) => {
try {
const uid = parseInt(req.params.id);
const u = await getRow('SELECT id, username, role FROM users WHERE id = ?', [uid]);
if (!u) return res.status(404).json({ error: '用户不存在' });
if (u.role === 'owner' && req.user.role !== 'owner') return res.status(403).json({ error: '无权修改服主' });
const { source, game_name, game_uid } = req.body;
if (!['netease','skin'].includes(source)) return res.status(400).json({ error: '无效的来源' });
if (!game_name) return res.status(400).json({ error: '游戏名不能为空' });
if (source === 'netease' && !game_uid) return res.status(400).json({ error: '网易端必须填写UID' });
const dup = await getRow('SELECT id FROM user_identities WHERE user_id = ? AND source = ?', [uid, source]);
if (dup) return res.status(400).json({ error: '该来源已绑定,可先删除再重新绑定' });
const dupName = await getRow('SELECT id FROM user_identities WHERE source = ? AND game_name = ?', [source, game_name]);
if (dupName) return res.status(400).json({ error: '该游戏名已绑定其他账号' });
const r = await query('INSERT INTO user_identities(user_id, source, game_name, game_uid) VALUES (?,?,?,?)',
[uid, source, game_name, game_uid || '']);
await query("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)",
[req.user.id, req.user.username, 'add_identity', 'user', uid, `${u.username || uid} 绑定 ${source} 身份 ${game_name}`]);
res.status(201).json({ id: r.insertId, message: '绑定成功' });
} catch (err) {
console.error('[users]', err);
res.status(500).json({ error: '服务器内部错误' });
}
});
router.delete('/:id/identities/:identityId', authenticate, requireRole('owner','admin'), async (req, res) => {
try {
const uid = parseInt(req.params.id);
const iid = parseInt(req.params.identityId);
const u = await getRow('SELECT id, username, role FROM users WHERE id = ?', [uid]);
if (!u) return res.status(404).json({ error: '用户不存在' });
if (u.role === 'owner' && req.user.role !== 'owner') return res.status(403).json({ error: '无权修改服主' });
const ident = await getRow('SELECT * FROM user_identities WHERE id = ? AND user_id = ?', [iid, uid]);
if (!ident) return res.status(404).json({ error: '身份不存在' });
const count = await getRow('SELECT COUNT(*) as c FROM user_identities WHERE user_id = ?', [uid]);
if (count.c <= 1) return res.status(400).json({ error: '至少保留一个身份' });
await query('DELETE FROM user_identities WHERE id = ?', [iid]);
await query("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)",
[req.user.id, req.user.username, 'remove_identity', 'user', uid, `解绑 ${ident.source} 身份 ${ident.game_name}`]);
res.json({ message: '已解绑' });
} catch (err) {
console.error('[users]', err);
res.status(500).json({ error: '服务器内部错误' });
}
});
router.post('/', authenticate, requireRole('owner','admin'), async (req, res) => { router.post('/', authenticate, requireRole('owner','admin'), async (req, res) => {
const { username, password, email, game_name, game_uid, role, source } = req.body; const { username, password, email, game_name, game_uid, role, source } = req.body;
if (!username||!password||!email||!game_name||!game_uid||!role) return res.status(400).json({ error: '所有字段必填' }); if (!username||!password||!email||!game_name||!game_uid||!role) return res.status(400).json({ error: '所有字段必填' });

View File

@@ -212,7 +212,7 @@ Base URL: `http://<host>:3100/api`
## 四、用户管理 Users *(admin/owner)* ## 四、用户管理 Users *(admin/owner)*
### GET /users *(admin/owner)*、GET /users/:id *(admin/owner)* ### GET /users *(admin/owner)*、GET /users/:id *(admin/owner)*
用户列表 / 详情(含管辖子服务器)。 用户列表(含 `identity_count` 身份数)/ 详情(含 `identities` 全部身份、管辖子服务器)。
### POST /users *(admin/owner)* ### POST /users *(admin/owner)*
创建用户(自动写入 user_identities 主身份)。角色 owner 仅服主可建。 创建用户(自动写入 user_identities 主身份)。角色 owner 仅服主可建。
@@ -220,6 +220,16 @@ Base URL: `http://<host>:3100/api`
### PUT /users/:id *(admin/owner)* ### PUT /users/:id *(admin/owner)*
更新邮箱/游戏名/UID/来源/角色/状态/密码/管辖服务器。主身份字段变化自动同步 `user_identities` 更新邮箱/游戏名/UID/来源/角色/状态/密码/管辖服务器。主身份字段变化自动同步 `user_identities`
### POST /users/:id/identities *(admin/owner)*
管理员为用户绑定额外来源身份(多身份管理)。
```json
{ "source": "skin", "game_name": "Alex", "game_uid": "SKIN_UUID" }
```
规则同 `POST /auth/identities`(同来源唯一、游戏名全局唯一、网易端必填 UID)。
### DELETE /users/:id/identities/:identityId *(admin/owner)*
管理员解绑用户身份(至少保留一个)。
### GET /users/:id/servers *(admin/owner)* ### GET /users/:id/servers *(admin/owner)*
管辖子服务器列表。 管辖子服务器列表。

View File

@@ -14,12 +14,13 @@ const UsersPage = {
const srcLabel = { netease:'网易', skin:'皮肤站' }; const srcLabel = { netease:'网易', skin:'皮肤站' };
ct.innerHTML = ` ct.innerHTML = `
<div class="card table-wrap"><table><thead><tr> <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><th>用户名</th><th>邮箱</th><th>游戏名</th><th>游戏UID</th><th>来源</th><th>身份</th><th>角色</th><th>状态</th><th>时间</th><th>操作</th>
</tr></thead><tbody>${users.map(u => ` </tr></thead><tbody>${users.map(u => `
<tr> <tr>
<td>${u.id}</td><td>${U.esc(u.username)}</td><td>${U.esc(u.email)}</td> <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>${U.esc(u.game_name)}</td><td>${U.esc(u.game_uid)}</td>
<td>${srcLabel[u.source] || u.source || '网易'}</td> <td>${srcLabel[u.source] || u.source || '网易'}</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.badge(u.role,'role')}</td>
<td>${u.active ? '<span class="badge bg-resolved">启用</span>' : '<span class="badge bg-rejected">禁用</span>'}</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 class="ts">${U.date(u.created_at)}</td>
@@ -79,9 +80,10 @@ const UsersPage = {
}, },
async loadUserServers(id, username, email, gameName, gameUid, role, active, source) { async loadUserServers(id, username, email, gameName, gameUid, role, active, source) {
let groups = [], userServers = []; let groups = [], userServers = [], identities = [];
try { groups = await API.get('/polls/groups'); } catch {} try { groups = await API.get('/polls/groups'); } catch {}
try { userServers = await API.get('/users/'+id+'/servers'); } catch {} try { userServers = await API.get('/users/'+id+'/servers'); } catch {}
try { identities = await API.get('/users/'+id).then(u => u.identities || []); } catch {}
const checks = groups.map(g => { const checks = groups.map(g => {
const key = g.group_name+'::'+g.server_name; const key = g.group_name+'::'+g.server_name;
const has = userServers.some(s => s.group_name===g.group_name && s.server_name===g.server_name); const has = userServers.some(s => s.group_name===g.group_name && s.server_name===g.server_name);
@@ -96,6 +98,23 @@ const UsersPage = {
<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="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> <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>
<div class="grid-2"><div class="form-group"><label>新密码(留空不变)</label><input type="password" id="eu-p"></div><div class="form-group"><label>状态</label><select id="eu-a"><option value="1" ${active?'selected':''}>启用</option><option value="0" ${!active?'selected':''}>禁用</option></select></div></div> <div class="grid-2"><div class="form-group"><label>新密码(留空不变)</label><input type="password" id="eu-p"></div><div class="form-group"><label>状态</label><select id="eu-a"><option value="1" ${active?'selected':''}>启用</option><option value="0" ${!active?'selected':''}>禁用</option></select></div></div>
<div class="form-group"><label>已绑定身份 (多来源)</label>
<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>${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>'}
</div>`).join('')}
</div>
<div style="display:flex;gap:6px;align-items:center">
<select id="eu-ident-source" style="padding:6px;border:1px solid var(--g300);border-radius:var(--r);font-size:12px"><option value="netease">网易端</option><option value="skin">皮肤站</option></select>
<input id="eu-ident-name" placeholder="游戏名" style="flex:1;padding:6px;border:1px solid var(--g300);border-radius:var(--r);font-size:12px">
<input id="eu-ident-uid" placeholder="UID(网易必填)" style="flex:1;padding:6px;border:1px solid var(--g300);border-radius:var(--r);font-size:12px">
<button type="button" class="btn btn-p btn-sm" onclick="UsersPage.addIdentity(${id})"><i class="fas fa-plus"></i> 绑定</button>
</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> <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> </form>
`); `);
@@ -112,5 +131,47 @@ const UsersPage = {
const pw = document.getElementById('eu-p').value; if (pw) data.password = pw; const pw = document.getElementById('eu-p').value; if (pw) data.password = pw;
try { await API.put('/users/'+id, data); App.closeModal(); this.load(); } catch (ex) { alert(ex.message); } try { await API.put('/users/'+id, data); App.closeModal(); this.load(); } catch (ex) { alert(ex.message); }
}; };
},
async addIdentity(userId) {
const source = document.getElementById('eu-ident-source').value;
const name = document.getElementById('eu-ident-name').value.trim();
const uid = document.getElementById('eu-ident-uid').value.trim();
if (!name) return alert('请填写游戏名');
try {
await API.post(`/users/${userId}/identities`, { source, game_name: name, game_uid: uid });
alert('绑定成功');
// 刷新弹窗中的身份列表
const u = await API.get('/users/'+userId);
const box = document.getElementById('identities-box');
const list = u.identities || [];
box.innerHTML = list.length===0 ? '<span class="tm">暂无身份记录</span>' : list.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>${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>
${list.length>1?`<button type="button" class="btn btn-d btn-sm" onclick="UsersPage.removeIdentity(${userId},${it.id})"><i class="fas fa-unlink"></i></button>`:'<span class="tm ts">主身份</span>'}
</div>`).join('');
document.getElementById('eu-ident-name').value = '';
document.getElementById('eu-ident-uid').value = '';
} catch (ex) { alert(ex.message); }
},
async removeIdentity(userId, identityId) {
if (!await U.confirm('确认解绑该身份?')) return;
try {
await API.delete(`/users/${userId}/identities/${identityId}`);
alert('已解绑');
const u = await API.get('/users/'+userId);
const box = document.getElementById('identities-box');
const list = u.identities || [];
box.innerHTML = list.length===0 ? '<span class="tm">暂无身份记录</span>' : list.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>${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>
${list.length>1?`<button type="button" class="btn btn-d btn-sm" onclick="UsersPage.removeIdentity(${userId},${it.id})"><i class="fas fa-unlink"></i></button>`:'<span class="tm ts">主身份</span>'}
</div>`).join('');
} catch (ex) { alert(ex.message); }
} }
}; };