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:
2026-08-17 01:17:09 +08:00
parent 42d4cafedd
commit 559970c5b7
8 changed files with 196 additions and 7 deletions

View File

@@ -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 = {};