feat: one account can bind both netease + skin identities
- db: user_identities table (UNIQUE user_id+source), migrate backfill from users - auth: GET/POST/DELETE /api/auth/identities - bind/unbind/list identities (netease requires UID, one per source, name uniqueness, keep >=1) - register/external/plugin/admin-created users auto-write primary identity - tickets: submit uses selected identity (validated belongs to user) - dashboard: 我的身份 card with bind/unbind UI - ticket-create: identity selector when >1 identity
This commit is contained in:
@@ -88,6 +88,17 @@ async function initSchema(connection) {
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
|
||||
await createIfNotExists('user_identities', `CREATE TABLE user_identities (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
source ENUM('netease','skin') NOT NULL,
|
||||
game_name VARCHAR(50) NOT NULL,
|
||||
game_uid VARCHAR(50) NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_user_source (user_id, source),
|
||||
INDEX idx_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
|
||||
await createIfNotExists('tickets', `CREATE TABLE tickets (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT,
|
||||
@@ -291,6 +302,22 @@ async function migrateAdditions(db) {
|
||||
try { await db.execute(`CREATE TABLE IF NOT EXISTS bans (id INT AUTO_INCREMENT PRIMARY KEY, player_name VARCHAR(50) NOT NULL, player_uid VARCHAR(50), source VARCHAR(10) DEFAULT '', reason TEXT, type ENUM('ban','mute','warn','other') NOT NULL DEFAULT 'ban', duration VARCHAR(20) DEFAULT '', ticket_id INT, created_by INT, status ENUM('active','expired','appealed','lifted') NOT NULL DEFAULT 'active', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, expires_at DATETIME) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } catch {}
|
||||
try { await db.execute("ALTER TABLE bans ADD COLUMN source VARCHAR(10) DEFAULT ''"); } catch {}
|
||||
|
||||
// 多来源身份: 建表 + 从 users 主身份回填(幂等)
|
||||
try {
|
||||
await db.execute(`CREATE TABLE IF NOT EXISTS user_identities (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
source ENUM('netease','skin') NOT NULL,
|
||||
game_name VARCHAR(50) NOT NULL,
|
||||
game_uid VARCHAR(50) NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_user_source (user_id, source),
|
||||
INDEX idx_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
await db.execute(`INSERT IGNORE INTO user_identities (user_id, source, game_name, game_uid)
|
||||
SELECT id, source, game_name, game_uid FROM users WHERE game_name != ''`);
|
||||
} catch {}
|
||||
|
||||
try { await db.execute(`CREATE TABLE IF NOT EXISTS polls (id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, description TEXT, options JSON NOT NULL, group_name VARCHAR(100) NOT NULL DEFAULT '', server_name VARCHAR(100) NOT NULL DEFAULT '', start_time DATETIME, end_time DATETIME, active TINYINT(1) NOT NULL DEFAULT 1, created_by INT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_active (active), INDEX idx_group_server (group_name, server_name)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } catch {}
|
||||
try { await db.execute(`CREATE TABLE IF NOT EXISTS poll_votes (id INT AUTO_INCREMENT PRIMARY KEY, poll_id INT NOT NULL, user_id INT NOT NULL, option_index INT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_poll_user (poll_id, user_id), INDEX idx_poll (poll_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } catch {}
|
||||
try { await db.execute(`CREATE TABLE IF NOT EXISTS feature_items (id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, description TEXT, group_name VARCHAR(100) NOT NULL DEFAULT '', server_name VARCHAR(100) NOT NULL DEFAULT '', status ENUM('pending','planned','done') NOT NULL DEFAULT 'pending', created_at DATETIME DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } catch {}
|
||||
|
||||
@@ -79,10 +79,14 @@ router.post('/register', validateLengths({
|
||||
}
|
||||
|
||||
const hashed = await bcrypt.hash(password, 10);
|
||||
await query(
|
||||
const r = await query(
|
||||
'INSERT INTO users(username, password, email, game_name, game_uid, active, email_verified, source) VALUES (?,?,?,?,?,?,?,?)',
|
||||
[username, hashed, email, game_name, game_uid||'', 1, 1, source || 'netease']
|
||||
);
|
||||
try {
|
||||
await query('INSERT INTO user_identities(user_id, source, game_name, game_uid) VALUES (?,?,?,?)',
|
||||
[r.insertId, source || 'netease', game_name, game_uid || '']);
|
||||
} catch {}
|
||||
res.status(201).json({ message: '注册成功,请登录' });
|
||||
} catch (err) {
|
||||
console.error('[auth]', err);
|
||||
@@ -148,10 +152,11 @@ router.post('/login', async (req, res) => {
|
||||
router.get('/me', authenticate, async (req, res) => {
|
||||
try {
|
||||
const user = await getRow(
|
||||
'SELECT id, username, email, game_name, game_uid, role, email_verified, created_at FROM users WHERE id = ?',
|
||||
'SELECT id, username, email, game_name, game_uid, role, email_verified, source, created_at FROM users WHERE id = ?',
|
||||
[req.user.id]
|
||||
);
|
||||
if (!user) return res.status(404).json({ error: '用户不存在' });
|
||||
user.identities = await query('SELECT id, source, game_name, game_uid, created_at FROM user_identities WHERE user_id = ? ORDER BY id', [req.user.id]);
|
||||
res.json(user);
|
||||
} catch (err) {
|
||||
console.error('[auth]', err);
|
||||
@@ -159,6 +164,54 @@ router.get('/me', authenticate, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 多来源身份管理(一个账号可同时绑定网易端 + 皮肤站) ----
|
||||
router.get('/identities', authenticate, async (req, res) => {
|
||||
try {
|
||||
const rows = await query('SELECT id, source, game_name, game_uid, created_at FROM user_identities WHERE user_id = ? ORDER BY id', [req.user.id]);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error('[auth]', err);
|
||||
res.status(500).json({ error: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/identities', authenticate, async (req, res) => {
|
||||
try {
|
||||
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 = ?', [req.user.id, 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 (?,?,?,?)',
|
||||
[req.user.id, source, game_name, game_uid || '']);
|
||||
res.status(201).json({ id: r.insertId, message: '绑定成功' });
|
||||
} catch (err) {
|
||||
console.error('[auth]', err);
|
||||
res.status(500).json({ error: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/identities/:id', authenticate, async (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const ident = await getRow('SELECT * FROM user_identities WHERE id = ? AND user_id = ?', [id, req.user.id]);
|
||||
if (!ident) return res.status(404).json({ error: '身份不存在' });
|
||||
const count = await getRow('SELECT COUNT(*) as c FROM user_identities WHERE user_id = ?', [req.user.id]);
|
||||
if (count.c <= 1) return res.status(400).json({ error: '至少保留一个身份' });
|
||||
await query('DELETE FROM user_identities WHERE id = ?', [id]);
|
||||
res.json({ message: '已解绑' });
|
||||
} catch (err) {
|
||||
console.error('[auth]', err);
|
||||
res.status(500).json({ error: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/profile', authenticate, async (req, res) => {
|
||||
try {
|
||||
const fields = {};
|
||||
|
||||
@@ -29,7 +29,11 @@ router.post('/auth/register', async (req, res) => {
|
||||
|
||||
const hashed = bcrypt.hashSync(password, 10);
|
||||
const verifyToken = uuid();
|
||||
await query('INSERT INTO users(username,password,email,game_name,game_uid,verify_token,verify_expires,source) VALUES (?,?,?,?,?,?,DATE_ADD(NOW(), INTERVAL 24 HOUR),?)', [username, hashed, email, game_name, game_uid, verifyToken, source || 'skin']);
|
||||
const r = await query('INSERT INTO users(username,password,email,game_name,game_uid,verify_token,verify_expires,source) VALUES (?,?,?,?,?,?,DATE_ADD(NOW(), INTERVAL 24 HOUR),?)', [username, hashed, email, game_name, game_uid, verifyToken, source || 'skin']);
|
||||
try {
|
||||
await query('INSERT INTO user_identities(user_id, source, game_name, game_uid) VALUES (?,?,?,?)',
|
||||
[r.insertId, source || 'skin', game_name, game_uid]);
|
||||
} catch {}
|
||||
|
||||
const site = await getRow("SELECT v FROM settings WHERE k='site_url'");
|
||||
const sent = await sendEmail(email, 'verify_email', { username, game_name, game_uid, verify_link: `${site?.v||'http://localhost:3100'}#/verify?token=${verifyToken}` });
|
||||
|
||||
@@ -107,8 +107,15 @@ router.post('/', ticketAnonLimiter, optionalAuth, upload.array('files', 5), fina
|
||||
const { type, title, reporter_game_name, reporter_game_uid, target_game_name, target_game_uid, reason, description, is_admin_complaint, parent_ticket_id } = req.body;
|
||||
if (!type || !['report','suggestion','appeal','result_appeal'].includes(type)) return res.status(400).json({ error: '类型不正确' });
|
||||
if (!title) return res.status(400).json({ error: '标题不能为空' });
|
||||
const rgn = req.user?.game_name || reporter_game_name;
|
||||
const rgu = req.user?.game_uid || reporter_game_uid;
|
||||
// 身份解析: 登录用户可选用自己绑定的任一身份(网易端/皮肤站); 匿名用表单值
|
||||
let rgn = reporter_game_name, rgu = reporter_game_uid;
|
||||
if (req.user) {
|
||||
if (reporter_game_name && reporter_game_name !== req.user.game_name) {
|
||||
const ident = await getRow('SELECT id FROM user_identities WHERE user_id = ? AND game_name = ?', [req.user.id, reporter_game_name]);
|
||||
if (!ident) return res.status(403).json({ error: '所选身份不属于当前账号' });
|
||||
}
|
||||
if (!rgn) { rgn = req.user.game_name; rgu = req.user.game_uid; }
|
||||
}
|
||||
if (!rgn || !rgu) return res.status(400).json({ error: '请填写游戏名称和UID' });
|
||||
if (type === 'report') { if (!target_game_name && !target_game_uid) return res.status(400).json({ error: '举报需至少填写对方游戏名或UID之一' }); if (!reason) return res.status(400).json({ error: '请填写举报原因' }); }
|
||||
if (type === 'suggestion' && !description) return res.status(400).json({ error: '建议内容不能为空' });
|
||||
|
||||
@@ -30,6 +30,10 @@ router.post('/', authenticate, requireRole('owner','admin'), async (req, res) =>
|
||||
if (await getRow('SELECT id FROM users WHERE email = ?', [email])) return res.status(400).json({ error: '邮箱已被使用' });
|
||||
const hashed = bcrypt.hashSync(password, 10);
|
||||
const r = await query('INSERT INTO users(username, password, email, game_name, game_uid, role, source, active, email_verified) VALUES (?,?,?,?,?,?,?,1,1)', [username, hashed, email, game_name, game_uid, role, source || 'netease']);
|
||||
try {
|
||||
await query('INSERT INTO user_identities(user_id, source, game_name, game_uid) VALUES (?,?,?,?)',
|
||||
[r.insertId, source || 'netease', game_name, game_uid]);
|
||||
} catch {}
|
||||
await query("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)", [req.user.id, req.user.username, 'create_user', 'user', r.insertId, `创建用户 ${username}`]);
|
||||
res.status(201).json({ id: r.insertId, message: '创建成功' });
|
||||
});
|
||||
@@ -50,6 +54,17 @@ router.put('/:id', authenticate, requireRole('owner','admin'), async (req, res)
|
||||
const sets = Object.keys(fields).map(k => `${k} = ?`).join(', ');
|
||||
await query(`UPDATE users SET ${sets} WHERE id = ?`, [...Object.values(fields), req.params.id]);
|
||||
|
||||
// 主身份字段变化时同步 user_identities 中对应来源
|
||||
if (fields.game_name !== undefined || fields.source !== undefined || fields.game_uid !== undefined) {
|
||||
const cur = await getRow('SELECT source, game_name, game_uid FROM users WHERE id = ?', [req.params.id]);
|
||||
if (cur?.game_name) {
|
||||
try {
|
||||
await query('INSERT INTO user_identities(user_id, source, game_name, game_uid) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE game_name = VALUES(game_name), game_uid = VALUES(game_uid)',
|
||||
[req.params.id, cur.source || 'netease', cur.game_name, cur.game_uid || '']);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
if (req.body.admin_servers !== undefined) {
|
||||
await query('DELETE FROM user_servers WHERE user_id = ?', [req.params.id]);
|
||||
if (Array.isArray(req.body.admin_servers)) {
|
||||
|
||||
Reference in New Issue
Block a user