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:
@@ -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: '所有字段必填' });
|
||||||
|
|||||||
12
docs/API.md
12
docs/API.md
@@ -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)*
|
||||||
管辖子服务器列表。
|
管辖子服务器列表。
|
||||||
|
|
||||||
|
|||||||
@@ -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); }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user