diff --git a/backend/routes/users.js b/backend/routes/users.js index 8e9be60..a79a805 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -6,13 +6,17 @@ const { authenticate, requireRole } = require('../middleware/auth'); const router = express.Router(); 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) => { 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: '用户不存在' }); 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); }); @@ -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])); }); +// ---- 管理员: 为用户绑定额外来源身份(多身份管理) ---- +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) => { 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: '所有字段必填' }); diff --git a/docs/API.md b/docs/API.md index 29b627e..fe5c804 100644 --- a/docs/API.md +++ b/docs/API.md @@ -212,7 +212,7 @@ Base URL: `http://:3100/api` ## 四、用户管理 Users *(admin/owner)* ### GET /users *(admin/owner)*、GET /users/:id *(admin/owner)* -用户列表 / 详情(含管辖子服务器)。 +用户列表(含 `identity_count` 身份数)/ 详情(含 `identities` 全部身份、管辖子服务器)。 ### POST /users *(admin/owner)* 创建用户(自动写入 user_identities 主身份)。角色 owner 仅服主可建。 @@ -220,6 +220,16 @@ Base URL: `http://:3100/api` ### PUT /users/:id *(admin/owner)* 更新邮箱/游戏名/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)* 管辖子服务器列表。 diff --git a/public/js/pages/users.js b/public/js/pages/users.js index bef898b..c59b421 100644 --- a/public/js/pages/users.js +++ b/public/js/pages/users.js @@ -14,12 +14,13 @@ const UsersPage = { const srcLabel = { netease:'网易', skin:'皮肤站' }; ct.innerHTML = `
- + ${users.map(u => ` + @@ -79,9 +80,10 @@ const UsersPage = { }, 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 { 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 key = g.group_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 = {
${checks||'无可用服务器(先在投票→分组管理中添加)'}
+
+
+ ${identities.length===0?'暂无身份记录':identities.map(it=>` +
+ ${it.source==='netease'?'网易端':'皮肤站'} + ${U.esc(it.game_name)}${it.game_uid?' (UID: '+U.esc(it.game_uid)+')':''} + ${U.date(it.created_at)} + ${identities.length>1?``:'主身份'} +
`).join('')} +
+
+ + + + +
+
`); @@ -112,5 +131,47 @@ const UsersPage = { 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); } }; + }, + + 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 ? '暂无身份记录' : list.map(it=>` +
+ ${it.source==='netease'?'网易端':'皮肤站'} + ${U.esc(it.game_name)}${it.game_uid?' (UID: '+U.esc(it.game_uid)+')':''} + ${U.date(it.created_at)} + ${list.length>1?``:'主身份'} +
`).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 ? '暂无身份记录' : list.map(it=>` +
+ ${it.source==='netease'?'网易端':'皮肤站'} + ${U.esc(it.game_name)}${it.game_uid?' (UID: '+U.esc(it.game_uid)+')':''} + ${U.date(it.created_at)} + ${list.length>1?``:'主身份'} +
`).join(''); + } catch (ex) { alert(ex.message); } } };
#用户名邮箱游戏名游戏UID来源角色状态时间操作#用户名邮箱游戏名游戏UID来源身份角色状态时间操作
${u.id}${U.esc(u.username)}${U.esc(u.email)} ${U.esc(u.game_name)}${U.esc(u.game_uid)} ${srcLabel[u.source] || u.source || '网易'}${(u.identity_count||0) > 1 ? `${u.identity_count} 个` : (u.identity_count||0) === 1 ? '1' : '-'} ${U.badge(u.role,'role')} ${u.active ? '启用' : '禁用'} ${U.date(u.created_at)}