diff --git a/backend/db.js b/backend/db.js index e512e10..f305c57 100644 --- a/backend/db.js +++ b/backend/db.js @@ -75,11 +75,11 @@ async function initSchema(connection) { password VARCHAR(255) NOT NULL, email VARCHAR(100) NOT NULL UNIQUE, game_name VARCHAR(50) NOT NULL, - game_uid VARCHAR(50) NOT NULL, + game_uid VARCHAR(50) NOT NULL DEFAULT '', role ENUM('owner','admin','player') NOT NULL DEFAULT 'player', active TINYINT(1) NOT NULL DEFAULT 0, email_verified TINYINT(1) NOT NULL DEFAULT 0, - source ENUM('netease','skin') NOT NULL DEFAULT 'netease', + source VARCHAR(20) NOT NULL DEFAULT '', verify_token VARCHAR(255), verify_expires DATETIME, reset_token VARCHAR(255), @@ -91,7 +91,7 @@ async function initSchema(connection) { 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, + source VARCHAR(20) NOT NULL, game_name VARCHAR(50) NOT NULL, game_uid VARCHAR(50) NOT NULL DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, @@ -99,6 +99,25 @@ async function initSchema(connection) { INDEX idx_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await createIfNotExists('sources', `CREATE TABLE sources ( + id INT AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(20) NOT NULL UNIQUE, + label VARCHAR(50) NOT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + + await createIfNotExists('api_sessions', `CREATE TABLE api_sessions ( + id INT AUTO_INCREMENT PRIMARY KEY, + client_id VARCHAR(64) NOT NULL, + session_token VARCHAR(128) NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_client (client_id), + INDEX idx_expires (expires_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await createIfNotExists('tickets', `CREATE TABLE tickets ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT, @@ -108,7 +127,7 @@ async function initSchema(connection) { status ENUM('pending','processing','awaiting_info','appealing','resolved','rejected','closed') NOT NULL DEFAULT 'pending', priority ENUM('low','medium','high','urgent') NOT NULL DEFAULT 'medium', reporter_game_name VARCHAR(50) NOT NULL, - reporter_game_uid VARCHAR(50) NOT NULL, + reporter_game_uid VARCHAR(50) NOT NULL DEFAULT '', target_game_name VARCHAR(50), target_game_uid VARCHAR(50), reason VARCHAR(500), @@ -391,6 +410,20 @@ async function migrateAdditions(db) { try { await db.execute(`CREATE TABLE IF NOT EXISTS api_clients (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, client_id VARCHAR(64) NOT NULL UNIQUE, secret_hash VARCHAR(255) NOT NULL, active TINYINT(1) NOT NULL DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_active (active)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } catch {} try { await db.execute("ALTER TABLE tickets ADD COLUMN server_name VARCHAR(100) NOT NULL DEFAULT ''"); } catch {} try { await db.execute("ALTER TABLE bans ADD COLUMN server_name VARCHAR(100) NOT NULL DEFAULT ''"); } catch {} + + // ---- 动态来源: sources 表 + users/user_identities.source 改 VARCHAR + game_uid 可空 ---- + try { await db.execute(`CREATE TABLE IF NOT EXISTS sources (id INT AUTO_INCREMENT PRIMARY KEY, code VARCHAR(20) NOT NULL UNIQUE, label VARCHAR(50) NOT NULL, enabled TINYINT(1) NOT NULL DEFAULT 1, sort_order INT NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } catch {} + try { + // 预置来源(幂等), 老数据来源保持兼容 + await db.execute("INSERT IGNORE INTO sources(code, label, sort_order) VALUES ('netease','网易端',1), ('skin','皮肤站',2)"); + } catch {} + try { await db.execute("ALTER TABLE users MODIFY source VARCHAR(20) NOT NULL DEFAULT ''"); } catch {} + try { await db.execute("ALTER TABLE users MODIFY game_uid VARCHAR(50) NOT NULL DEFAULT ''"); } catch {} + try { await db.execute("ALTER TABLE user_identities MODIFY source VARCHAR(20) NOT NULL"); } catch {} + try { await db.execute("ALTER TABLE tickets MODIFY reporter_game_uid VARCHAR(50) NOT NULL DEFAULT ''"); } catch {} + + // ---- API SESSION 表 ---- + try { await db.execute(`CREATE TABLE IF NOT EXISTS api_sessions (id INT AUTO_INCREMENT PRIMARY KEY, client_id VARCHAR(64) NOT NULL, session_token VARCHAR(128) NOT NULL UNIQUE, expires_at DATETIME NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_client (client_id), INDEX idx_expires (expires_at)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } catch {} } async function seedTemplates(db) { diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 7ac31b0..565749e 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -46,7 +46,15 @@ router.post('/register', validateLengths({ if (!username || !password || !email || !game_name) { return res.status(400).json({ error: '所有字段均为必填' }); } - if (source === 'netease' && !game_uid) return res.status(400).json({ error: '网易端必须填写UID' }); + // 来源必须是已启用的动态来源(不设默认) + let srcValid = false; + if (source) { + try { + const srcRow = await getRow('SELECT id FROM sources WHERE code = ? AND enabled = 1', [source]); + srcValid = !!srcRow; + } catch { srcValid = true; } // sources 表不存在时兼容旧部署 + } + if (source && !srcValid) return res.status(400).json({ error: '无效的来源' }); if (!code_id || !code) return res.status(400).json({ error: '请先完成邮箱验证' }); const codeRow = await getRow("SELECT * FROM captchas WHERE id = ? AND created_at > NOW() - INTERVAL 10 MINUTE", [code_id]); @@ -74,24 +82,24 @@ router.post('/register', validateLengths({ if (existingUser || existingEmail) { return res.status(400).json({ error: '用户名或邮箱已被使用' }); } - if (game_name && (source || 'netease') === 'netease') { - const dup = await getRow('SELECT id FROM users WHERE game_name = ? AND source = ?', [game_name, source || 'netease']); + if (game_name && source) { + const dup = await getRow('SELECT id FROM users WHERE game_name = ? AND source = ?', [game_name, source]); if (dup) return res.status(400).json({ error: '该游戏名在此来源下已注册' }); } // 多来源兼容: 该游戏名可能已被其他账号绑定为副身份 try { - const dupIdent = await getRow('SELECT id FROM user_identities WHERE source = ? AND game_name = ?', [source || 'netease', game_name]); + const dupIdent = await getRow('SELECT id FROM user_identities WHERE source = ? AND game_name = ?', [source || '', game_name]); if (dupIdent) return res.status(400).json({ error: '该游戏名已绑定其他账号' }); } catch {} const hashed = await bcrypt.hash(password, 10); 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'] + [username, hashed, email, game_name, game_uid||'', 1, 1, source || ''] ); try { await query('INSERT INTO user_identities(user_id, source, game_name, game_uid) VALUES (?,?,?,?)', - [r.insertId, source || 'netease', game_name, game_uid || '']); + [r.insertId, source || '', game_name, game_uid || '']); } catch {} res.status(201).json({ message: '注册成功,请登录' }); } catch (err) { @@ -184,18 +192,23 @@ router.get('/identities', authenticate, async (req, res) => { 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' }); + // 来源动态校验(启用中的来源; sources 表缺失时兼容) + if (source) { + try { + const srcRow = await getRow('SELECT id FROM sources WHERE code = ? AND enabled = 1', [source]); + if (!srcRow) return res.status(400).json({ error: '无效的来源' }); + } catch {} + } - const dup = await getRow('SELECT id FROM user_identities WHERE user_id = ? AND source = ?', [req.user.id, source]); + 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]); + 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 || '']); + [req.user.id, source || '', game_name, game_uid || '']); res.status(201).json({ id: r.insertId, message: '绑定成功' }); } catch (err) { console.error('[auth]', err); diff --git a/backend/routes/external.js b/backend/routes/external.js index d50ea8d..24a5eb8 100644 --- a/backend/routes/external.js +++ b/backend/routes/external.js @@ -10,19 +10,46 @@ const { logSystem } = require('../logger'); const router = express.Router(); // ============ 鉴权 ============ -// 外部 API 唯一鉴权方式: ID + Secret(api_clients 表) -// 无凭据 / 凭据错误 / 客户端停用 → 一律 401(不允许匿名访问) +// 流程: 客户端用 ID + Secret 换取 SESSION(短期有效), 之后所有请求用 +// Authorization: Bearer +// 无 SESSION / SESSION 无效或过期 / 客户端停用 → 一律 401 + +const SESSION_TTL_HOURS = 24; + +function genClientId() { + // 16 位纯数字, 随机不递增 + let id = ''; + for (let i = 0; i < 16; i++) id += crypto.randomInt(0, 10); + return id; +} + +function genSecret() { + // SeaReport- + 32 位随机字符串 + return 'SeaReport-' + crypto.randomBytes(16).toString('hex'); // 16 bytes = 32 hex chars +} + +async function createSession(clientId) { + const token = crypto.randomBytes(48).toString('hex'); + await query( + 'INSERT INTO api_sessions(client_id, session_token, expires_at) VALUES (?,?,DATE_ADD(NOW(), INTERVAL ? HOUR))', + [clientId, token, SESSION_TTL_HOURS] + ); + return { session_token: token, expires_in: SESSION_TTL_HOURS * 3600 }; +} async function clientAuth(req, res, next) { - const cid = req.headers['x-api-client-id']; - const secret = req.headers['x-api-secret']; - if (!cid || !secret) return res.status(401).json({ error: '未授权: 缺少 x-api-client-id / x-api-secret' }); + const h = req.headers.authorization; + if (!h || !h.startsWith('Bearer ')) return res.status(401).json({ error: '未授权: 缺少 Authorization: Bearer ' }); + const token = h.split(' ')[1]; try { - const client = await getRow('SELECT * FROM api_clients WHERE client_id = ?', [cid]); - if (!client || !client.active || !bcrypt.compareSync(secret, client.secret_hash)) { - return res.status(401).json({ error: '客户端鉴权失败' }); - } - req.apiClient = client; + const row = await getRow( + `SELECT s.client_id, s.expires_at, c.active AS client_active + FROM api_sessions s JOIN api_clients c ON c.client_id = s.client_id + WHERE s.session_token = ? AND s.expires_at > NOW()`, + [token] + ); + if (!row || !row.client_active) return res.status(401).json({ error: 'SESSION 无效或已过期' }); + req.apiClient = { client_id: row.client_id }; next(); } catch (e) { res.status(500).json({ error: '鉴权服务异常' }); @@ -47,8 +74,8 @@ router.post('/clients', authenticate, async (req, res) => { if (!['owner','admin'].includes(req.user.role)) return res.status(403).json({ error: '无权限' }); const { name } = req.body; if (!name) return res.status(400).json({ error: '名称必填' }); - const clientId = 'c_' + crypto.randomBytes(12).toString('hex'); - const secret = 's_' + crypto.randomBytes(24).toString('hex'); + const clientId = genClientId(); + const secret = genSecret(); await query('INSERT INTO api_clients(name, client_id, secret_hash) VALUES (?,?,?)', [name, clientId, bcrypt.hashSync(secret, 10)]); await logSystem('info', 'api', `创建外部API客户端: ${name}`, { client_id: clientId }); res.json({ client_id: clientId, secret, message: '请立即保存 secret(仅显示一次)' }); @@ -82,24 +109,43 @@ router.delete('/clients/:id', authenticate, async (req, res) => { } catch (e) { res.status(500).json({ error: e.message }); } }); -// ---- 以下接口: 外部鉴权(client_id + secret, 唯一方式) ---- +// ============ 换取 SESSION(ID + Secret → Bearer SESSION) ============ +// 仅此一个接口用 ID+Secret 鉴权, 其余接口一律 Bearer SESSION +router.post('/auth/session', async (req, res) => { + try { + const cid = req.headers['x-api-client-id']; + const secret = req.headers['x-api-secret']; + if (!cid || !secret) return res.status(401).json({ error: '缺少 x-api-client-id / x-api-secret' }); + const client = await getRow('SELECT * FROM api_clients WHERE client_id = ?', [cid]); + if (!client || !client.active || !bcrypt.compareSync(secret, client.secret_hash)) { + return res.status(401).json({ error: '客户端鉴权失败' }); + } + // 清理该客户端旧 SESSION(单会话) + await query('DELETE FROM api_sessions WHERE client_id = ?', [cid]); + const sess = await createSession(cid); + await logSystem('info', 'api', `外部API客户端换取SESSION: ${client.name}`, { client_id: cid }); + res.json({ ...sess, client_id: cid, message: `SESSION 有效期 ${SESSION_TTL_HOURS} 小时, 请用 Authorization: Bearer 访问其余接口` }); + } catch (e) { res.status(500).json({ error: '服务器错误' }); } +}); + +// ---- 以下接口: 外部鉴权(Bearer SESSION, 唯一方式) ---- router.use(clientAuth); // ============ 外部注册(需客户端鉴权) ============ router.post('/auth/register', async (req, res) => { try { const { username, password, email, game_name, game_uid, source } = req.body; - if (!username || !password || !email || !game_name || !game_uid) return res.status(400).json({ error: '所有字段必填' }); + if (!username || !password || !email || !game_name) return res.status(400).json({ error: '所有字段必填' }); if (password.length < 6) return res.status(400).json({ error: '密码至少6位' }); if (await getRow('SELECT id FROM users WHERE username = ?', [username])) return res.status(400).json({ error: '注册失败,请检查输入信息' }); if (await getRow('SELECT id FROM users WHERE email = ?', [email])) return res.status(400).json({ error: '注册失败,请检查输入信息' }); const hashed = bcrypt.hashSync(password, 10); const verifyToken = uuid(); - 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']); + 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 || '']); try { await query('INSERT INTO user_identities(user_id, source, game_name, game_uid) VALUES (?,?,?,?)', - [r.insertId, source || 'skin', game_name, game_uid]); + [r.insertId, source || '', game_name, game_uid || '']); } catch {} const site = await getRow("SELECT v FROM settings WHERE k='site_url'"); @@ -119,7 +165,7 @@ router.post('/tickets', async (req, res) => { try { const { type, title, reporter_game_name, reporter_game_uid, target_game_name, target_game_uid, reason, description, is_admin_complaint, server } = req.body; if (!type || !['report','suggestion','appeal'].includes(type)) return res.status(400).json({ error: '类型不正确' }); - if (!title || !reporter_game_name || !reporter_game_uid) return res.status(400).json({ error: '必填字段不完整' }); + if (!title || !reporter_game_name) return res.status(400).json({ error: '必填字段不完整' }); if (type === 'report' && !reason) return res.status(400).json({ error: '请填写举报原因' }); if (type === 'suggestion' && !description) return res.status(400).json({ error: '建议内容不能为空' }); if (type === 'appeal' && (!reason || !description)) return res.status(400).json({ error: '请填写完整申诉信息' }); @@ -130,8 +176,8 @@ router.post('/tickets', async (req, res) => { const sv = await resolveServer(server); const trackingToken = uuid(); const r = await query(`INSERT INTO tickets(type,title,reporter_game_name,reporter_game_uid,target_game_name,target_game_uid,reason,description,tracking_token,is_admin_complaint,server_name) VALUES (?,?,?,?,?,?,?,?,?,?,?)`, - [type, title, reporter_game_name, reporter_game_uid, target_game_name||'', target_game_uid||'', reason||'', description||'', trackingToken, is_admin_complaint?1:0, sv.server_name]); - await query("INSERT INTO responses(ticket_id,content,is_staff) VALUES (?,?,0)", [r.insertId, `游戏内提交\n类型: ${type}\n提交人: ${reporter_game_name} (UID: ${reporter_game_uid})${sv.server_name?'\n服务器: '+sv.server_name:''}`]); + [type, title, reporter_game_name, reporter_game_uid||'', target_game_name||'', target_game_uid||'', reason||'', description||'', trackingToken, is_admin_complaint?1:0, sv.server_name]); + await query("INSERT INTO responses(ticket_id,content,is_staff) VALUES (?,?,0)", [r.insertId, `游戏内提交\n类型: ${type}\n提交人: ${reporter_game_name}${reporter_game_uid?' (UID: '+reporter_game_uid+')':''}${sv.server_name?'\n服务器: '+sv.server_name:''}`]); res.status(201).json({ id: r.insertId, tracking_token: trackingToken, server: sv.server_name }); } catch (e) { res.status(500).json({ error: e.message }); } }); diff --git a/backend/routes/sources.js b/backend/routes/sources.js new file mode 100644 index 0000000..d45524a --- /dev/null +++ b/backend/routes/sources.js @@ -0,0 +1,54 @@ +const express = require('express'); +const { query, getRow } = require('../db'); +const { authenticate, requireRole } = require('../middleware/auth'); +const { logSystem } = require('../logger'); + +const router = express.Router(); + +// ---- 来源列表(公开: 注册页/绑定身份页需要) ---- +router.get('/', async (req, res) => { + try { + const rows = await query('SELECT code, label, sort_order FROM sources WHERE enabled = 1 ORDER BY sort_order, id'); + res.json(rows); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +// ---- 管理(owner) ---- +router.post('/', authenticate, requireRole('owner'), async (req, res) => { + try { + const { code, label, sort_order } = req.body; + if (!code || !label) return res.status(400).json({ error: 'code 和 label 必填' }); + if (!/^[a-z0-9_]{1,20}$/.test(code)) return res.status(400).json({ error: 'code 仅允许小写字母数字下划线(≤20)' }); + const dup = await getRow('SELECT id FROM sources WHERE code = ?', [code]); + if (dup) return res.status(400).json({ error: '该来源已存在' }); + const r = await query('INSERT INTO sources(code, label, sort_order) VALUES (?,?,?)', [code, label, parseInt(sort_order) || 0]); + await logSystem('info', 'source', `新增来源: ${code}(${label})`); + res.status(201).json({ id: r.insertId, message: '已添加' }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.put('/:code', authenticate, requireRole('owner'), async (req, res) => { + try { + const { label, enabled, sort_order } = req.body; + const row = await getRow('SELECT id FROM sources WHERE code = ?', [req.params.code]); + if (!row) return res.status(404).json({ error: '来源不存在' }); + await query('UPDATE sources SET label = ?, enabled = ?, sort_order = ? WHERE code = ?', + [label || req.params.code, enabled ? 1 : 0, parseInt(sort_order) || 0, req.params.code]); + await logSystem('info', 'source', `更新来源: ${req.params.code}`, { label, enabled: !!enabled }); + res.json({ message: '已更新' }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.delete('/:code', authenticate, requireRole('owner'), async (req, res) => { + try { + // 有关联数据时禁止删除(防误删) + const usedUsers = await getRow('SELECT COUNT(*) as c FROM users WHERE source = ?', [req.params.code]); + const usedIdent = await getRow('SELECT COUNT(*) as c FROM user_identities WHERE source = ?', [req.params.code]); + if (usedUsers.c > 0 || usedIdent.c > 0) return res.status(400).json({ error: `该来源已被 ${usedUsers.c + usedIdent.c} 条身份数据使用, 请先停用` }); + await query('DELETE FROM sources WHERE code = ?', [req.params.code]); + await logSystem('info', 'source', `删除来源: ${req.params.code}`); + res.json({ message: '已删除' }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +module.exports = router; diff --git a/backend/routes/tickets.js b/backend/routes/tickets.js index e5bbf7f..f0eed42 100644 --- a/backend/routes/tickets.js +++ b/backend/routes/tickets.js @@ -116,7 +116,7 @@ router.post('/', ticketAnonLimiter, optionalAuth, upload.array('files', 5), fina } if (!rgn) { rgn = req.user.game_name; rgu = req.user.game_uid; } } - if (!rgn || !rgu) return res.status(400).json({ error: '请填写游戏名称和UID' }); + if (!rgn) return res.status(400).json({ error: '请填写游戏名称' }); 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: '建议内容不能为空' }); if (type === 'appeal') { if (!reason) return res.status(400).json({ error: '请填写申诉理由' }); if (!description) return res.status(400).json({ error: '请填写详细申诉内容' }); } @@ -143,7 +143,7 @@ router.post('/', ticketAnonLimiter, optionalAuth, upload.array('files', 5), fina await conn.beginTransaction(); const [r] = await conn.execute(`INSERT INTO tickets(type,title,user_id,reporter_game_name,reporter_game_uid, target_game_name,target_game_uid,reason,description,tracking_token,is_admin_complaint,parent_ticket_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, - [type, title, req.user?.id||null, rgn, rgu, + [type, title, req.user?.id||null, rgn, rgu||'', type==='report'?(target_game_name||''):null, type==='report'?(target_game_uid||''):null, (type==='report'||type==='appeal'||type==='result_appeal')?reason:null, (type==='suggestion'||type==='appeal'||type==='result_appeal')?description:'', diff --git a/backend/routes/users.js b/backend/routes/users.js index a79a805..42657d7 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -33,18 +33,23 @@ router.post('/:id/identities', authenticate, requireRole('owner','admin'), async 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' }); + // 来源动态校验 + if (source) { + try { + const srcRow = await getRow('SELECT id FROM sources WHERE code = ? AND enabled = 1', [source]); + if (!srcRow) return res.status(400).json({ error: '无效的来源' }); + } catch {} + } - const dup = await getRow('SELECT id FROM user_identities WHERE user_id = ? AND source = ?', [uid, source]); + 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]); + 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 || '']); + [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: '绑定成功' }); @@ -78,17 +83,17 @@ router.delete('/:id/identities/:identityId', authenticate, requireRole('owner',' 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: '所有字段必填' }); + if (!username||!password||!email||!game_name||!role) return res.status(400).json({ error: '所有字段必填' }); if (!['owner','admin','player'].includes(role)) return res.status(400).json({ error: '无效角色' }); if (role === 'owner' && req.user.role !== 'owner') return res.status(403).json({ error: '仅服主可创建服主账号' }); if (password.length < 6) return res.status(400).json({ error: '密码至少6位' }); if (await getRow('SELECT id FROM users WHERE username = ?', [username])) return res.status(400).json({ error: '用户名已存在' }); 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']); + 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 || '']); try { await query('INSERT INTO user_identities(user_id, source, game_name, game_uid) VALUES (?,?,?,?)', - [r.insertId, source || 'netease', game_name, game_uid]); + [r.insertId, source || '', 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: '创建成功' }); @@ -102,7 +107,16 @@ router.put('/:id', authenticate, requireRole('owner','admin'), async (req, res) if (req.body.email) fields.email = req.body.email; if (req.body.game_name) fields.game_name = req.body.game_name; if (req.body.game_uid) fields.game_uid = req.body.game_uid; - if (req.body.source) { if (!['netease','skin'].includes(req.body.source)) return res.status(400).json({ error: '无效来源' }); fields.source = req.body.source; } + if (req.body.source) { + // 动态来源校验 + let srcOk = false; + try { + const srcRow = await getRow('SELECT id FROM sources WHERE code = ? AND enabled = 1', [req.body.source]); + srcOk = !!srcRow; + } catch { srcOk = true; } + if (!srcOk) return res.status(400).json({ error: '无效来源' }); + fields.source = req.body.source; + } if (req.body.role) { if (!['owner','admin','player'].includes(req.body.role)) return res.status(400).json({ error: '无效角色' }); fields.role = req.body.role; } if (req.body.active !== undefined) fields.active = req.body.active ? 1 : 0; if (req.body.password) { if (req.body.password.length < 6) return res.status(400).json({ error: '密码至少6位' }); fields.password = bcrypt.hashSync(req.body.password, 10); } @@ -116,7 +130,7 @@ router.put('/:id', authenticate, requireRole('owner','admin'), async (req, res) 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 || '']); + [req.params.id, cur.source || '', cur.game_name, cur.game_uid || '']); } catch {} } } diff --git a/backend/server.js b/backend/server.js index b7197cb..3804af6 100644 --- a/backend/server.js +++ b/backend/server.js @@ -129,6 +129,7 @@ function loadBusinessRoutes() { businessRouter.use('/features', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/features')); businessRouter.use('/bans', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/bans')); businessRouter.use('/logs', methodGuard(['GET']), generalLimiter, require('./routes/logs')); + businessRouter.use('/sources', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/sources')); businessRouter.use('/external', require('./routes/external')); businessRouter.get('/verify', (req, res) => res.redirect(`/#/verify?token=${req.query.token}`)); diff --git a/docs/API.md b/docs/API.md index 95ee1d9..5f95fad 100644 --- a/docs/API.md +++ b/docs/API.md @@ -346,11 +346,12 @@ Base URL: `http://:3100/api` | PUT /external/clients/:id | Bearer(owner/admin) | 启用/停用客户端 | | DELETE /external/clients/:id | Bearer(owner/admin) | 删除客户端 | -### 外部鉴权(唯一方式) +### 外部鉴权(SESSION 机制) -| 方式 | 请求头 | 说明 | +| 阶段 | 请求头 | 说明 | |------|--------|------| -| ID + Secret(唯一) | `x-api-client-id` + `x-api-secret` | 后台可创建/停用多个客户端;无凭据或凭据错误一律 401 | +| 换取 SESSION | `x-api-client-id` + `x-api-secret` | 仅 `POST /external/auth/session` 使用;ID 为 16 位随机数字,Secret 为 `SeaReport-` + 32 位 | +| 后续请求 | `Authorization: Bearer ` | 所有其余接口;24 小时有效,重换即旧 SESSION 失效,客户端停用立即失效 | ### 子服务器定位(`server` 参数) diff --git a/docs/EXTERNAL-API.md b/docs/EXTERNAL-API.md index 857097e..2871a44 100644 --- a/docs/EXTERNAL-API.md +++ b/docs/EXTERNAL-API.md @@ -2,31 +2,60 @@ 适用对象:QQ 官方机器人、游戏服务器插件、统计面板等**无法做网页登录**的外部系统。 -- Base URL:`https://<你的域名>/api/external` +- Base URL:`https://report.sea-studio.top/api/external` - 数据格式:JSON(`Content-Type: application/json`) -- 本文档所有接口**不需要用户登录**,只需要客户端凭据(见下) +- 本文档所有接口都需要客户端凭据(见下) --- -## 一、鉴权(唯一方式:ID + Secret) +## 一、鉴权流程(ID + Secret 换取 SESSION) 在站点后台 →「外部API」页面创建客户端,获得一对凭据: ```text -Client ID: c_1a2b3c4d5e6f7a8b9c0d1e2f -Secret: s_9f8e7d6c5b4a39281726354a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d3e +Client ID: 1829473056482917 # 16 位纯数字, 随机生成 +Secret: SeaReport-a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4 # SeaReport- + 32 位 ``` > ⚠️ Secret 只在创建时显示一次,请立即保存。支持创建多个客户端、单独停用/删除,互不影响。 -> 无凭据 / 凭据错误 / 客户端停用 → 一律返回 `401`。 -调用时在请求头携带: +### 第一步:换取 SESSION(唯一使用 ID+Secret 的接口) + +``` +POST /api/external/auth/session +``` + +请求头: | 请求头 | 值 | |--------|-----| | `x-api-client-id` | 你的 Client ID | | `x-api-secret` | 你的 Secret | +**成功响应 200:** + +```json +{ + "session_token": "5f8a2b3c...96位十六进制", + "expires_in": 86400, + "client_id": "1829473056482917", + "message": "SESSION 有效期 24 小时, 请用 Authorization: Bearer 访问其余接口" +} +``` + +### 第二步:后续请求携带 SESSION + +所有其余接口(工单/封禁/统计等)使用: + +| 请求头 | 值 | +|--------|-----| +| `Authorization` | `Bearer ` | + +- SESSION 有效期 **24 小时**;过期后重新调用第一步换取。 +- 同一客户端重新换取时,**旧 SESSION 立即失效**(单会话)。 +- 客户端被停用 → 已发 SESSION 立即失效(401)。 +- 无 SESSION / SESSION 无效或过期 → 一律 `401`。 + --- ## 二、快速开始(Python / Node 示例) @@ -37,20 +66,21 @@ Secret: s_9f8e7d6c5b4a39281726354a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d3e import requests BASE = "https://你的域名/api/external" -HEADERS = { - "x-api-client-id": "c_1a2b3c4d5e6f7a8b9c0d1e2f", - "x-api-secret": "s_9f8e7d6c...", - "Content-Type": "application/json", -} -# 提交举报工单 +# 1) 用 ID + Secret 换取 SESSION +r = requests.post(f"{BASE}/auth/session", headers={ + "x-api-client-id": "1829473056482917", + "x-api-secret": "SeaReport-a1b2c3d4...", +}) +session = r.json()["session_token"] +HEADERS = {"Authorization": f"Bearer {session}", "Content-Type": "application/json"} + +# 2) 提交举报工单(带 SESSION) r = requests.post(f"{BASE}/tickets", headers=HEADERS, json={ "type": "report", "title": "恶意破坏", "reporter_game_name": "Steve", - "reporter_game_uid": "df273bda10b94aa18db345574d5a1e1d", "target_game_name": "Alex", - "target_game_uid": "SKIN_UUID_002", "reason": "刷屏+破坏他人建筑", "description": "多次警告无效", "server": "survival", # 可选: 子服别名 @@ -62,13 +92,16 @@ print(r.json()) # {"id": 123, "tracking_token": "..."} ```javascript const BASE = 'https://你的域名/api/external'; -const H = { - 'x-api-client-id': 'c_1a2b3c...', - 'x-api-secret': 's_9f8e7d6c...', - 'Content-Type': 'application/json', -}; -// 查询工单进度(从提交到结束全程可查) +// 1) 换取 SESSION +const authRes = await fetch(`${BASE}/auth/session`, { + method: 'POST', + headers: { 'x-api-client-id': '1829473056482917', 'x-api-secret': 'SeaReport-a1b2c3d4...' }, +}); +const { session_token } = await authRes.json(); +const H = { Authorization: `Bearer ${session_token}`, 'Content-Type': 'application/json' }; + +// 2) 查询工单进度(从提交到结束全程可查) const res = await fetch(`${BASE}/tickets/track?token=你的tracking_token`, { headers: H }); console.log(await res.json()); ``` @@ -91,6 +124,12 @@ console.log(await res.json()); ## 四、接口清单 +### 0. 换取 SESSION(见第一节, 其余接口均需 Bearer SESSION) + +``` +POST /api/external/auth/session +``` + ### 1. 提交工单 ``` @@ -102,7 +141,7 @@ POST /api/external/tickets | type | string | ✅ | `report` / `suggestion` / `appeal` | | title | string | ✅ | 标题 | | reporter_game_name | string | ✅ | 提交人游戏名 | -| reporter_game_uid | string | ✅ | 提交人 UID / UUID | +| reporter_game_uid | string | 否 | 提交人 UID / UUID(选填) | | target_game_name | string | 条件 | 被举报人游戏名(report 建议填) | | target_game_uid | string | 条件 | 被举报人 UID | | reason | string | 条件 | 举报原因(report/appeal 必填) | diff --git a/public/index.html b/public/index.html index 9c087f8..b10ebd1 100644 --- a/public/index.html +++ b/public/index.html @@ -76,6 +76,7 @@ + diff --git a/public/js/app.js b/public/js/app.js index 01401e7..0b9d4cb 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -10,6 +10,7 @@ const App = { { id: 'features', label: '更新内容', icon: 'fa-lightbulb', roles: ['owner'] }, { id: 'notifications', label: '通知配置', icon: 'fa-bell', roles: ['owner'] }, { id: 'external-api', label: '外部API', icon: 'fa-key', roles: ['owner'] }, + { id: 'sources', label: '来源管理', icon: 'fa-tags', roles: ['owner'] }, { id: 'templates', label: '邮件模板', icon: 'fa-envelope', roles: ['owner'] }, { id: 'settings', label: '系统设置', icon: 'fa-cog', roles: ['owner'] }, { id: 'logs', label: '系统日志', icon: 'fa-clipboard-list', roles: ['owner','admin'] }, @@ -94,6 +95,7 @@ const App = { case 'users': this.renderMain('用户管理', UsersPage, param); break; case 'notifications': this.renderMain('通知配置', NotificationsPage, param); break; case 'external-api': this.renderMain('外部API', ExternalApiPage, param); break; + case 'sources': this.renderMain('来源管理', SourcesPage, param); break; case 'templates': this.renderMain('邮件模板', TemplatesPage, param); break; case 'settings': this.renderMain('系统设置', SettingsPage, param); break; case 'export': this.renderMain('数据导出', ExportPage, param); break; @@ -176,6 +178,7 @@ window.UsersPage = UsersPage; window.TemplatesPage = TemplatesPage; window.NotificationsPage = NotificationsPage; window.ExternalApiPage = ExternalApiPage; +window.SourcesPage = SourcesPage; window.SettingsPage = SettingsPage; window.ExportPage = ExportPage; window.PollsPage = PollsPage; diff --git a/public/js/pages/bans.js b/public/js/pages/bans.js index fe18637..4f34c1c 100644 --- a/public/js/pages/bans.js +++ b/public/js/pages/bans.js @@ -39,12 +39,16 @@ const BansPage = { U.modal('添加封禁', `
-
+
`); + U.sources().then(list => { + const sel = document.getElementById('ban-source'); + if (sel) sel.innerHTML = '' + list.map(s => ``).join(''); + }).catch(()=>{}); document.getElementById('ban-form').onsubmit = async e => { e.preventDefault(); try { @@ -80,12 +84,16 @@ const BansPage = { U.modal('编辑封禁', `
-
+
`); + U.sources().then(list => { + const sel = document.getElementById('be-source'); + if (sel) sel.innerHTML = '' + list.map(s => ``).join(''); + }).catch(()=>{}); document.getElementById('be-form').onsubmit = async e => { e.preventDefault(); try { diff --git a/public/js/pages/dashboard.js b/public/js/pages/dashboard.js index 7e6006f..b676936 100644 --- a/public/js/pages/dashboard.js +++ b/public/js/pages/dashboard.js @@ -18,6 +18,14 @@ const Dashboard = { const bans = Auth.isAdmin() ? await API.get('/bans/active').catch(()=>[]) : []; const identities = await API.get('/auth/identities').catch(()=>[]); this.renderContent(ct, stats, exports, bans, identities); + // 渲染完成后将来源 code 替换为动态标签 + U.sources().then(() => { + ct.querySelectorAll('[data-src]').forEach(el => { + const code = el.dataset.src; + const hit = (U._sourcesCache||[]).find(s => s.code === code); + el.textContent = hit ? hit.label : (code || '-'); + }); + }).catch(()=>{}); } catch (e) { ct.innerHTML = `
${e.message}
`; } }, @@ -42,12 +50,12 @@ const Dashboard = {
我的身份 ${idents.length < 2 ? `` : ''}
${idents.length === 0 ? '暂无身份,点击右上角绑定' : `${idents.map(it=>` - + `).join('')}
来源游戏名UID绑定时间操作
${it.source==='netease'?'网易端':'皮肤站'}${U.esc(it.source||'-')} ${U.esc(it.game_name)}${U.esc(it.game_uid||'-')} ${U.date(it.created_at)} ${idents.length > 1 ? `` : '主身份'}
`} -

同一账号可同时绑定网易端与皮肤站身份,提交工单时选择使用哪个身份。

+

同一账号可绑定多个来源身份,提交工单时选择使用哪个身份。

@@ -71,12 +79,16 @@ const Dashboard = { U.modal('添加封禁', `
-
+
`); + U.sources().then(list => { + const sel = document.getElementById('ban-source'); + if (sel) sel.innerHTML = '' + list.map(s => ``).join(''); + }).catch(()=>{}); document.getElementById('ban-form').onsubmit = async e => { e.preventDefault(); try { @@ -90,9 +102,9 @@ const Dashboard = { showAddIdentity() { U.modal('绑定身份', `
-
+
-
+
-
接口速览(免用户登录, 需上述鉴权头)
+
接口速览(除换取SESSION外, 均需 Bearer SESSION)
方法路径说明
+ + diff --git a/public/js/pages/register.js b/public/js/pages/register.js index 25e0d3c..57cc68c 100644 --- a/public/js/pages/register.js +++ b/public/js/pages/register.js @@ -2,6 +2,17 @@ const RegisterPage = { captcha: null, codeId: null, + async mount() { + // 动态加载来源下拉(不设默认来源) + U.sources().then(list => { + const sel = document.getElementById('rs'); + if (sel) { + sel.innerHTML = '' + list.map(s => ``).join(''); + this.toggleSource(); + } + }).catch(()=>{}); + }, + async render() { try { this.captcha = await API.get('/captcha'); } catch { this.captcha = null; } return ` @@ -15,11 +26,11 @@ const RegisterPage = {
-
+
-
+ ${this.captcha ? `
@@ -55,9 +66,10 @@ const RegisterPage = { toggleSource() { const src = document.getElementById('rs').value; document.getElementById('rs-label1').textContent = '游戏名称'; - document.getElementById('rs-label2').textContent = src === 'netease' ? '网易UID' : '皮肤站UUID(自动获取)'; - document.getElementById('uid-group').style.display = src === 'netease' ? '' : 'none'; - document.getElementById('rgu').required = src === 'netease'; + const isSkin = src === 'skin'; + document.getElementById('rs-label2').textContent = isSkin ? '皮肤站UUID(可选)' : 'UID(可选)'; + document.getElementById('uid-group').style.display = isSkin ? 'none' : ''; + document.getElementById('rgu').required = false; }, async refreshCaptcha() { diff --git a/public/js/pages/sources.js b/public/js/pages/sources.js new file mode 100644 index 0000000..aeb6575 --- /dev/null +++ b/public/js/pages/sources.js @@ -0,0 +1,85 @@ +const SourcesPage = { + async render() { return '
'; }, + + async mount() { + document.getElementById('page-title').textContent = '来源管理'; + document.getElementById('page-actions').innerHTML = ''; + await this.load(); + }, + + async load() { + const ct = document.getElementById('page-content'); + try { + // 管理视图: 含停用项 + const rows = await API.get('/sources'); + ct.innerHTML = rows.length === 0 + ? '

暂无来源

' + : `
方法路径说明
POST/api/external/auth/sessionID+Secret 换取 SESSION(唯一使用 ID+Secret 的接口)
POST/api/external/auth/register插件注册账号(需 SESSION)
POST/api/external/tickets提交工单(举报/建议/申诉),返回 id + tracking_token;可带 server(别名或「分组/子服」)
GET/api/external/tickets/track?token=xxx按追踪码查工单状态 + 回复,从提交到结束全程可查
GET/api/external/all-tickets?server=&status=&type=&page=&limit=工单列表(可按子服过滤)
${rows.map(s => ` + + + + + + + `).join('')}
code名称排序状态操作
${U.esc(s.code)}${U.esc(s.label)}${s.sort_order}${s.enabled ? '启用' : '停用'} + + +
+

来源用于标识玩家身份归属(如: 网易端/皮肤站/正版/离线)。已被身份数据使用的来源不能删除, 只能停用。

`; + } catch (e) { ct.innerHTML = `
${e.message}
`; } + }, + + showAdd() { + U.modal('添加来源', ` +
+
+
+
+
+
+ +
+ `); + document.getElementById('src-form').onsubmit = async e => { + e.preventDefault(); + try { + await API.post('/sources', { + code: document.getElementById('src-code').value, + label: document.getElementById('src-label').value, + sort_order: document.getElementById('src-sort').value, + }); + App.closeModal(); this.load(); + } catch(ex){alert(ex.message);} + }; + }, + + showEdit(code, label, enabled, sortOrder) { + U.modal('编辑来源', ` +
+
+
+
+
+
+
+ +
+ `); + document.getElementById('src-edit-form').onsubmit = async e => { + e.preventDefault(); + try { + await API.put(`/sources/${code}`, { + label: document.getElementById('se-label').value, + enabled: document.getElementById('se-enabled').value === '1', + sort_order: document.getElementById('se-sort').value, + }); + App.closeModal(); this.load(); + } catch(ex){alert(ex.message);} + }; + }, + + async del(code) { + if (!await U.confirm(`确认删除来源 ${code}?`)) return; + try { await API.delete(`/sources/${code}`); this.load(); } catch(ex){alert(ex.message);} + } +}; diff --git a/public/js/pages/ticket-create.js b/public/js/pages/ticket-create.js index f8d7aa3..a34e935 100644 --- a/public/js/pages/ticket-create.js +++ b/public/js/pages/ticket-create.js @@ -16,7 +16,7 @@ const TicketCreate = { this.identities = list || []; const sel = container.querySelector('#identity-select'); if (sel && this.identities.length > 1) { - sel.innerHTML = this.identities.map((it, i) => ``).join(''); + sel.innerHTML = this.identities.map((it, i) => ``).join(''); sel.closest('.form-group').classList.remove('hidden'); } }).catch(() => {}); @@ -58,8 +58,8 @@ const TicketCreate = {
- - + +
diff --git a/public/js/pages/users.js b/public/js/pages/users.js index b925ec3..52b410b 100644 --- a/public/js/pages/users.js +++ b/public/js/pages/users.js @@ -11,7 +11,6 @@ const UsersPage = { const ct = document.getElementById('page-content'); try { const users = await API.get('/users'); - const srcLabel = { netease:'网易', skin:'皮肤站' }; ct.innerHTML = `
@@ -19,14 +18,21 @@ const UsersPage = { - + - + `).join('')}
#用户名邮箱游戏名游戏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.esc(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)}
`; + // 来源 code → 动态标签 + U.sources().then(() => { + ct.querySelectorAll('[data-src]').forEach(el => { + const hit = (U._sourcesCache||[]).find(s => s.code === el.dataset.src); + if (hit) el.textContent = hit.label; + }); + }).catch(()=>{}); } catch (e) { ct.innerHTML = `
${e.message}
`; } }, @@ -44,13 +50,18 @@ const UsersPage = { U.modal('添加用户', `
-
+
-
+
`); + // 动态加载来源下拉 + U.sources().then(list => { + const sel = document.getElementById('cu-s'); + if (sel) sel.innerHTML = '' + list.map(s => ``).join(''); + }).catch(()=>{}); document.getElementById('cu-form').onsubmit = async e => { e.preventDefault(); const servers = []; @@ -93,7 +104,7 @@ const UsersPage = { U.modal('编辑用户', `
-
+
${checks||'无可用服务器(先在投票→分组管理中添加)'}
@@ -102,7 +113,7 @@ const UsersPage = {
${identities.length===0?'暂无身份记录':identities.map(it=>`
- ${it.source==='netease'?'网易端':'皮肤站'} + ${U.esc(it.source||'-')} ${U.esc(it.game_name)}${it.game_uid?' (UID: '+U.esc(it.game_uid)+')':''} ${U.date(it.created_at)} ${identities.length>1?``:'主身份'} @@ -110,10 +121,10 @@ const UsersPage = {
- +
@@ -139,9 +150,31 @@ const UsersPage = { try { await API.put('/users/'+id, data); App.closeModal(); this.load(); } catch (ex) { alert(ex.message); } }; + // 动态加载来源(主身份下拉 + 身份绑定下拉), 并替换 data-src 标签 + U.sources().then(list => { + const opts = list.map(s => ``).join(''); + const sel = document.getElementById('eu-s'); + if (sel && opts) { + const cur = sel.value; + sel.innerHTML = opts; + if (cur && list.some(s => s.code === cur)) sel.value = cur; + } + const sel2 = document.getElementById('eu-ident-source'); + if (sel2 && opts) { + sel2.innerHTML = opts; + euToggleSource(); + } + document.querySelectorAll('#eu-form [data-src]').forEach(el => { + const hit = list.find(s => s.code === el.dataset.src); + if (hit) el.textContent = hit.label; + }); + }).catch(()=>{}); + // 来源切换: 皮肤站时显示 UUID 查询辅助 const euToggleSource = () => { - const isSkin = document.getElementById('eu-ident-source').value === 'skin'; + const sel = document.getElementById('eu-ident-source'); + if (!sel) return; + const isSkin = sel.value === 'skin'; document.getElementById('eu-ident-site-group').classList.toggle('hidden', !isSkin); }; document.getElementById('eu-ident-source').onchange = euToggleSource; diff --git a/public/js/utils.js b/public/js/utils.js index 0aa95c6..e1052ca 100644 --- a/public/js/utils.js +++ b/public/js/utils.js @@ -30,6 +30,36 @@ const U = { return String(s).replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/"/g,'"').replace(/\n/g,'\\n'); }, + // ---- 动态来源(缓存, 注册/绑定身份/用户管理等处共用) ---- + _sourcesCache: null, + async sources(force) { + if (force || !this._sourcesCache) { + try { this._sourcesCache = await API.get('/sources'); } + catch { this._sourcesCache = []; } + } + return this._sourcesCache; + }, + // 生成下拉 HTML; includeEmpty 时带"不指定"选项 + async sourcesOptions(selected, includeEmpty) { + const list = await this.sources(); + const opts = (includeEmpty ? '' : '') + + list.map(s => ``).join(''); + return opts; + }, + // code → 显示标签(找不到时显示原 code) + async sourceLabel(code) { + if (!code) return '-'; + const list = await this.sources(); + const hit = list.find(s => s.code === code); + return hit ? hit.label : code; + }, + async sourceBadge(code) { + if (!code) return '-'; + const list = await this.sources(); + const hit = list.find(s => s.code === code); + return `${this.esc(hit ? hit.label : code)}`; + }, + showAlert(containerId, type, msg) { const ct = document.getElementById(containerId || 'page-content'); if (!ct) return;