refactor: session-based external auth, dynamic sources, drop netease UID

Auth (external API):
- ID: 16-digit random (non-sequential); Secret: SeaReport- + 32 hex
- POST /auth/session: ID+Secret -> Bearer SESSION (24h, single-session,
  old session invalidated on re-issue, disabled client invalidates)
- clientAuth now validates Bearer SESSION via api_sessions JOIN api_clients

Sources (dynamic, no default, open-source friendly):
- sources table + CRUD route (/api/sources, owner; delete guarded by usage)
- users/user_identities.source ENUM -> VARCHAR, seeded netease/skin
- register/admin create/identity bind: validate against enabled sources
- UI: 来源管理 page; source dropdowns loaded dynamically everywhere
  (register, dashboard identity, users admin, bans), labels dynamic

UID removal:
- game_uid/reporter_game_uid no longer required (db default '', validations
  dropped, frontend fields optional)

Docs: EXTERNAL-API.md session flow + new credential format; API.md updated
Verified: 37 checks (syntax, session logic, source CRUD, UID removal, docs)
This commit is contained in:
2026-08-19 20:08:06 +08:00
parent 6056153f57
commit 65edbaf157
19 changed files with 497 additions and 103 deletions

View File

@@ -75,11 +75,11 @@ async function initSchema(connection) {
password VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL,
email VARCHAR(100) NOT NULL UNIQUE, email VARCHAR(100) NOT NULL UNIQUE,
game_name VARCHAR(50) NOT NULL, 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', role ENUM('owner','admin','player') NOT NULL DEFAULT 'player',
active TINYINT(1) NOT NULL DEFAULT 0, active TINYINT(1) NOT NULL DEFAULT 0,
email_verified 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_token VARCHAR(255),
verify_expires DATETIME, verify_expires DATETIME,
reset_token VARCHAR(255), reset_token VARCHAR(255),
@@ -91,7 +91,7 @@ async function initSchema(connection) {
await createIfNotExists('user_identities', `CREATE TABLE user_identities ( await createIfNotExists('user_identities', `CREATE TABLE user_identities (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL, user_id INT NOT NULL,
source ENUM('netease','skin') NOT NULL, source VARCHAR(20) NOT NULL,
game_name VARCHAR(50) NOT NULL, game_name VARCHAR(50) NOT NULL,
game_uid VARCHAR(50) NOT NULL DEFAULT '', game_uid VARCHAR(50) NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -99,6 +99,25 @@ async function initSchema(connection) {
INDEX idx_user (user_id) INDEX idx_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); ) 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 ( await createIfNotExists('tickets', `CREATE TABLE tickets (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT, 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', status ENUM('pending','processing','awaiting_info','appealing','resolved','rejected','closed') NOT NULL DEFAULT 'pending',
priority ENUM('low','medium','high','urgent') NOT NULL DEFAULT 'medium', priority ENUM('low','medium','high','urgent') NOT NULL DEFAULT 'medium',
reporter_game_name VARCHAR(50) NOT NULL, 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_name VARCHAR(50),
target_game_uid VARCHAR(50), target_game_uid VARCHAR(50),
reason VARCHAR(500), 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(`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 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 {} 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) { async function seedTemplates(db) {

View File

@@ -46,7 +46,15 @@ router.post('/register', validateLengths({
if (!username || !password || !email || !game_name) { if (!username || !password || !email || !game_name) {
return res.status(400).json({ error: '所有字段均为必填' }); 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: '请先完成邮箱验证' }); 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]); 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) { if (existingUser || existingEmail) {
return res.status(400).json({ error: '用户名或邮箱已被使用' }); return res.status(400).json({ error: '用户名或邮箱已被使用' });
} }
if (game_name && (source || 'netease') === 'netease') { if (game_name && source) {
const dup = await getRow('SELECT id FROM users WHERE game_name = ? AND source = ?', [game_name, source || 'netease']); const dup = await getRow('SELECT id FROM users WHERE game_name = ? AND source = ?', [game_name, source]);
if (dup) return res.status(400).json({ error: '该游戏名在此来源下已注册' }); if (dup) return res.status(400).json({ error: '该游戏名在此来源下已注册' });
} }
// 多来源兼容: 该游戏名可能已被其他账号绑定为副身份 // 多来源兼容: 该游戏名可能已被其他账号绑定为副身份
try { 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: '该游戏名已绑定其他账号' }); if (dupIdent) return res.status(400).json({ error: '该游戏名已绑定其他账号' });
} catch {} } catch {}
const hashed = await bcrypt.hash(password, 10); const hashed = await bcrypt.hash(password, 10);
const r = await query( const r = await query(
'INSERT INTO users(username, password, email, game_name, game_uid, active, email_verified, source) VALUES (?,?,?,?,?,?,?,?)', '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 { try {
await query('INSERT INTO user_identities(user_id, source, game_name, game_uid) VALUES (?,?,?,?)', 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 {} } catch {}
res.status(201).json({ message: '注册成功,请登录' }); res.status(201).json({ message: '注册成功,请登录' });
} catch (err) { } catch (err) {
@@ -184,18 +192,23 @@ router.get('/identities', authenticate, async (req, res) => {
router.post('/identities', authenticate, async (req, res) => { router.post('/identities', authenticate, async (req, res) => {
try { try {
const { source, game_name, game_uid } = req.body; 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 (!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: '该来源已绑定,可先删除再重新绑定' }); 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: '该游戏名已绑定其他账号' }); if (dupName) return res.status(400).json({ error: '该游戏名已绑定其他账号' });
const r = await query('INSERT INTO user_identities(user_id, source, game_name, game_uid) VALUES (?,?,?,?)', 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: '绑定成功' }); res.status(201).json({ id: r.insertId, message: '绑定成功' });
} catch (err) { } catch (err) {
console.error('[auth]', err); console.error('[auth]', err);

View File

@@ -10,19 +10,46 @@ const { logSystem } = require('../logger');
const router = express.Router(); const router = express.Router();
// ============ 鉴权 ============ // ============ 鉴权 ============
// 外部 API 唯一鉴权方式: ID + Secret(api_clients 表) // 流程: 客户端用 ID + Secret 换取 SESSION(短期有效), 之后所有请求用
// 无凭据 / 凭据错误 / 客户端停用 → 一律 401(不允许匿名访问) // Authorization: Bearer <SESSION>
// 无 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) { async function clientAuth(req, res, next) {
const cid = req.headers['x-api-client-id']; const h = req.headers.authorization;
const secret = req.headers['x-api-secret']; if (!h || !h.startsWith('Bearer ')) return res.status(401).json({ error: '未授权: 缺少 Authorization: Bearer <SESSION>' });
if (!cid || !secret) return res.status(401).json({ error: '未授权: 缺少 x-api-client-id / x-api-secret' }); const token = h.split(' ')[1];
try { try {
const client = await getRow('SELECT * FROM api_clients WHERE client_id = ?', [cid]); const row = await getRow(
if (!client || !client.active || !bcrypt.compareSync(secret, client.secret_hash)) { `SELECT s.client_id, s.expires_at, c.active AS client_active
return res.status(401).json({ error: '客户端鉴权失败' }); FROM api_sessions s JOIN api_clients c ON c.client_id = s.client_id
} WHERE s.session_token = ? AND s.expires_at > NOW()`,
req.apiClient = client; [token]
);
if (!row || !row.client_active) return res.status(401).json({ error: 'SESSION 无效或已过期' });
req.apiClient = { client_id: row.client_id };
next(); next();
} catch (e) { } catch (e) {
res.status(500).json({ error: '鉴权服务异常' }); 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: '无权限' }); if (!['owner','admin'].includes(req.user.role)) return res.status(403).json({ error: '无权限' });
const { name } = req.body; const { name } = req.body;
if (!name) return res.status(400).json({ error: '名称必填' }); if (!name) return res.status(400).json({ error: '名称必填' });
const clientId = 'c_' + crypto.randomBytes(12).toString('hex'); const clientId = genClientId();
const secret = 's_' + crypto.randomBytes(24).toString('hex'); const secret = genSecret();
await query('INSERT INTO api_clients(name, client_id, secret_hash) VALUES (?,?,?)', [name, clientId, bcrypt.hashSync(secret, 10)]); 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 }); await logSystem('info', 'api', `创建外部API客户端: ${name}`, { client_id: clientId });
res.json({ client_id: clientId, secret, message: '请立即保存 secret(仅显示一次)' }); 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 }); } } 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 <session> 访问其余接口` });
} catch (e) { res.status(500).json({ error: '服务器错误' }); }
});
// ---- 以下接口: 外部鉴权(Bearer SESSION, 唯一方式) ----
router.use(clientAuth); router.use(clientAuth);
// ============ 外部注册(需客户端鉴权) ============ // ============ 外部注册(需客户端鉴权) ============
router.post('/auth/register', async (req, res) => { router.post('/auth/register', async (req, res) => {
try { try {
const { username, password, email, game_name, game_uid, source } = req.body; 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 (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 username = ?', [username])) return res.status(400).json({ error: '注册失败,请检查输入信息' });
if (await getRow('SELECT id FROM users WHERE email = ?', [email])) 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 hashed = bcrypt.hashSync(password, 10);
const verifyToken = uuid(); 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 { try {
await query('INSERT INTO user_identities(user_id, source, game_name, game_uid) VALUES (?,?,?,?)', 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 {} } catch {}
const site = await getRow("SELECT v FROM settings WHERE k='site_url'"); const site = await getRow("SELECT v FROM settings WHERE k='site_url'");
@@ -119,7 +165,7 @@ router.post('/tickets', async (req, res) => {
try { try {
const { type, title, reporter_game_name, reporter_game_uid, target_game_name, target_game_uid, reason, description, is_admin_complaint, server } = req.body; 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 (!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 === 'report' && !reason) return res.status(400).json({ error: '请填写举报原因' });
if (type === 'suggestion' && !description) 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: '请填写完整申诉信息' }); 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 sv = await resolveServer(server);
const trackingToken = uuid(); 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 (?,?,?,?,?,?,?,?,?,?,?)`, 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]); [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:''}`]); 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 }); res.status(201).json({ id: r.insertId, tracking_token: trackingToken, server: sv.server_name });
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });

54
backend/routes/sources.js Normal file
View File

@@ -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;

View File

@@ -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) { 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 === '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 === '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: '请填写详细申诉内容' }); } 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(); await conn.beginTransaction();
const [r] = await conn.execute(`INSERT INTO tickets(type,title,user_id,reporter_game_name,reporter_game_uid, 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 (?,?,?,?,?,?,?,?,?,?,?,?)`, 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'?(target_game_name||''):null, type==='report'?(target_game_uid||''):null,
(type==='report'||type==='appeal'||type==='result_appeal')?reason:null, (type==='report'||type==='appeal'||type==='result_appeal')?reason:null,
(type==='suggestion'||type==='appeal'||type==='result_appeal')?description:'', (type==='suggestion'||type==='appeal'||type==='result_appeal')?description:'',

View File

@@ -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: '无权修改服主' }); if (u.role === 'owner' && req.user.role !== 'owner') return res.status(403).json({ error: '无权修改服主' });
const { source, game_name, game_uid } = req.body; 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 (!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: '该来源已绑定,可先删除再重新绑定' }); 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: '该游戏名已绑定其他账号' }); if (dupName) return res.status(400).json({ error: '该游戏名已绑定其他账号' });
const r = await query('INSERT INTO user_identities(user_id, source, game_name, game_uid) VALUES (?,?,?,?)', 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 (?,?,?,?,?,?)", 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}`]); [req.user.id, req.user.username, 'add_identity', 'user', uid, `${u.username || uid} 绑定 ${source} 身份 ${game_name}`]);
res.status(201).json({ id: r.insertId, message: '绑定成功' }); 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) => { 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||!role) return res.status(400).json({ error: '所有字段必填' });
if (!['owner','admin','player'].includes(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 (role === 'owner' && req.user.role !== 'owner') return res.status(403).json({ error: '仅服主可创建服主账号' });
if (password.length < 6) return res.status(400).json({ error: '密码至少6位' }); 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 username = ?', [username])) return res.status(400).json({ error: '用户名已存在' });
if (await getRow('SELECT id FROM users WHERE email = ?', [email])) 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 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 { try {
await query('INSERT INTO user_identities(user_id, source, game_name, game_uid) VALUES (?,?,?,?)', 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 {} } 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}`]); 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: '创建成功' }); 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.email) fields.email = req.body.email;
if (req.body.game_name) fields.game_name = req.body.game_name; 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.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.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.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); } 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) { if (cur?.game_name) {
try { 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)', 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 {} } catch {}
} }
} }

View File

@@ -129,6 +129,7 @@ function loadBusinessRoutes() {
businessRouter.use('/features', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/features')); 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('/bans', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/bans'));
businessRouter.use('/logs', methodGuard(['GET']), generalLimiter, require('./routes/logs')); 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.use('/external', require('./routes/external'));
businessRouter.get('/verify', (req, res) => res.redirect(`/#/verify?token=${req.query.token}`)); businessRouter.get('/verify', (req, res) => res.redirect(`/#/verify?token=${req.query.token}`));

View File

@@ -346,11 +346,12 @@ Base URL: `http://<host>:3100/api`
| PUT /external/clients/:id | Bearer(owner/admin) | 启用/停用客户端 | | PUT /external/clients/:id | Bearer(owner/admin) | 启用/停用客户端 |
| DELETE /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 <SESSION>` | 所有其余接口;24 小时有效,重换即旧 SESSION 失效,客户端停用立即失效 |
### 子服务器定位(`server` 参数) ### 子服务器定位(`server` 参数)

View File

@@ -2,31 +2,60 @@
适用对象:QQ 官方机器人、游戏服务器插件、统计面板等**无法做网页登录**的外部系统。 适用对象:QQ 官方机器人、游戏服务器插件、统计面板等**无法做网页登录**的外部系统。
- Base URL:`https://<你的域名>/api/external` - Base URL:`https://report.sea-studio.top/api/external`
- 数据格式:JSON(`Content-Type: application/json`) - 数据格式:JSON(`Content-Type: application/json`)
- 本文档所有接口**不需要用户登录**,只需要客户端凭据(见下) - 本文档所有接口需要客户端凭据(见下)
--- ---
## 一、鉴权(唯一方式:ID + Secret) ## 一、鉴权流程(ID + Secret 换取 SESSION)
在站点后台 →「外部API」页面创建客户端,获得一对凭据: 在站点后台 →「外部API」页面创建客户端,获得一对凭据:
```text ```text
Client ID: c_1a2b3c4d5e6f7a8b9c0d1e2f Client ID: 1829473056482917 # 16 位纯数字, 随机生成
Secret: s_9f8e7d6c5b4a39281726354a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d3e Secret: SeaReport-a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4 # SeaReport- + 32 位
``` ```
> ⚠️ Secret 只在创建时显示一次,请立即保存。支持创建多个客户端、单独停用/删除,互不影响。 > ⚠️ Secret 只在创建时显示一次,请立即保存。支持创建多个客户端、单独停用/删除,互不影响。
> 无凭据 / 凭据错误 / 客户端停用 → 一律返回 `401`。
调用时在请求头携带: ### 第一步:换取 SESSION(唯一使用 ID+Secret 的接口)
```
POST /api/external/auth/session
```
请求头:
| 请求头 | 值 | | 请求头 | 值 |
|--------|-----| |--------|-----|
| `x-api-client-id` | 你的 Client ID | | `x-api-client-id` | 你的 Client ID |
| `x-api-secret` | 你的 Secret | | `x-api-secret` | 你的 Secret |
**成功响应 200:**
```json
{
"session_token": "5f8a2b3c...96位十六进制",
"expires_in": 86400,
"client_id": "1829473056482917",
"message": "SESSION 有效期 24 小时, 请用 Authorization: Bearer <session> 访问其余接口"
}
```
### 第二步:后续请求携带 SESSION
所有其余接口(工单/封禁/统计等)使用:
| 请求头 | 值 |
|--------|-----|
| `Authorization` | `Bearer <session_token>` |
- SESSION 有效期 **24 小时**;过期后重新调用第一步换取。
- 同一客户端重新换取时,**旧 SESSION 立即失效**(单会话)。
- 客户端被停用 → 已发 SESSION 立即失效(401)。
- 无 SESSION / SESSION 无效或过期 → 一律 `401`
--- ---
## 二、快速开始(Python / Node 示例) ## 二、快速开始(Python / Node 示例)
@@ -37,20 +66,21 @@ Secret: s_9f8e7d6c5b4a39281726354a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d3e
import requests import requests
BASE = "https://你的域名/api/external" 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={ r = requests.post(f"{BASE}/tickets", headers=HEADERS, json={
"type": "report", "type": "report",
"title": "恶意破坏", "title": "恶意破坏",
"reporter_game_name": "Steve", "reporter_game_name": "Steve",
"reporter_game_uid": "df273bda10b94aa18db345574d5a1e1d",
"target_game_name": "Alex", "target_game_name": "Alex",
"target_game_uid": "SKIN_UUID_002",
"reason": "刷屏+破坏他人建筑", "reason": "刷屏+破坏他人建筑",
"description": "多次警告无效", "description": "多次警告无效",
"server": "survival", # 可选: 子服别名 "server": "survival", # 可选: 子服别名
@@ -62,13 +92,16 @@ print(r.json()) # {"id": 123, "tracking_token": "..."}
```javascript ```javascript
const BASE = 'https://你的域名/api/external'; 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 }); const res = await fetch(`${BASE}/tickets/track?token=你的tracking_token`, { headers: H });
console.log(await res.json()); console.log(await res.json());
``` ```
@@ -91,6 +124,12 @@ console.log(await res.json());
## 四、接口清单 ## 四、接口清单
### 0. 换取 SESSION(见第一节, 其余接口均需 Bearer SESSION)
```
POST /api/external/auth/session
```
### 1. 提交工单 ### 1. 提交工单
``` ```
@@ -102,7 +141,7 @@ POST /api/external/tickets
| type | string | ✅ | `report` / `suggestion` / `appeal` | | type | string | ✅ | `report` / `suggestion` / `appeal` |
| title | string | ✅ | 标题 | | title | string | ✅ | 标题 |
| reporter_game_name | string | ✅ | 提交人游戏名 | | reporter_game_name | string | ✅ | 提交人游戏名 |
| reporter_game_uid | string | | 提交人 UID / UUID | | reporter_game_uid | string | | 提交人 UID / UUID(选填) |
| target_game_name | string | 条件 | 被举报人游戏名(report 建议填) | | target_game_name | string | 条件 | 被举报人游戏名(report 建议填) |
| target_game_uid | string | 条件 | 被举报人 UID | | target_game_uid | string | 条件 | 被举报人 UID |
| reason | string | 条件 | 举报原因(report/appeal 必填) | | reason | string | 条件 | 举报原因(report/appeal 必填) |

View File

@@ -76,6 +76,7 @@
<script src="js/pages/servers.js"></script> <script src="js/pages/servers.js"></script>
<script src="js/pages/bans.js"></script> <script src="js/pages/bans.js"></script>
<script src="js/pages/logs.js"></script> <script src="js/pages/logs.js"></script>
<script src="js/pages/sources.js"></script>
<script src="js/pages/features.js"></script> <script src="js/pages/features.js"></script>
<script src="js/pages/feature-detail.js"></script> <script src="js/pages/feature-detail.js"></script>
<script src="js/app.js"></script> <script src="js/app.js"></script>

View File

@@ -10,6 +10,7 @@ const App = {
{ id: 'features', label: '更新内容', icon: 'fa-lightbulb', roles: ['owner'] }, { id: 'features', label: '更新内容', icon: 'fa-lightbulb', roles: ['owner'] },
{ id: 'notifications', label: '通知配置', icon: 'fa-bell', roles: ['owner'] }, { id: 'notifications', label: '通知配置', icon: 'fa-bell', roles: ['owner'] },
{ id: 'external-api', label: '外部API', icon: 'fa-key', 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: 'templates', label: '邮件模板', icon: 'fa-envelope', roles: ['owner'] },
{ id: 'settings', label: '系统设置', icon: 'fa-cog', roles: ['owner'] }, { id: 'settings', label: '系统设置', icon: 'fa-cog', roles: ['owner'] },
{ id: 'logs', label: '系统日志', icon: 'fa-clipboard-list', roles: ['owner','admin'] }, { id: 'logs', label: '系统日志', icon: 'fa-clipboard-list', roles: ['owner','admin'] },
@@ -94,6 +95,7 @@ const App = {
case 'users': this.renderMain('用户管理', UsersPage, param); break; case 'users': this.renderMain('用户管理', UsersPage, param); break;
case 'notifications': this.renderMain('通知配置', NotificationsPage, param); break; case 'notifications': this.renderMain('通知配置', NotificationsPage, param); break;
case 'external-api': this.renderMain('外部API', ExternalApiPage, 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 'templates': this.renderMain('邮件模板', TemplatesPage, param); break;
case 'settings': this.renderMain('系统设置', SettingsPage, param); break; case 'settings': this.renderMain('系统设置', SettingsPage, param); break;
case 'export': this.renderMain('数据导出', ExportPage, param); break; case 'export': this.renderMain('数据导出', ExportPage, param); break;
@@ -176,6 +178,7 @@ window.UsersPage = UsersPage;
window.TemplatesPage = TemplatesPage; window.TemplatesPage = TemplatesPage;
window.NotificationsPage = NotificationsPage; window.NotificationsPage = NotificationsPage;
window.ExternalApiPage = ExternalApiPage; window.ExternalApiPage = ExternalApiPage;
window.SourcesPage = SourcesPage;
window.SettingsPage = SettingsPage; window.SettingsPage = SettingsPage;
window.ExportPage = ExportPage; window.ExportPage = ExportPage;
window.PollsPage = PollsPage; window.PollsPage = PollsPage;

View File

@@ -39,12 +39,16 @@ const BansPage = {
U.modal('添加封禁', ` U.modal('添加封禁', `
<form id="ban-form"> <form id="ban-form">
<div class="grid-2"><div class="form-group"><label>玩家名 *</label><input id="ban-name" required></div><div class="form-group"><label>UID</label><input id="ban-uid"></div></div> <div class="grid-2"><div class="form-group"><label>玩家名 *</label><input id="ban-name" required></div><div class="form-group"><label>UID</label><input id="ban-uid"></div></div>
<div class="grid-2"><div class="form-group"><label>来源</label><select id="ban-source"><option value="">未知</option><option value="netease">网易端</option><option value="skin">皮肤站</option></select></div><div class="form-group"><label>类型</label><select id="ban-type" onchange="BansPage.checkMinDuration()"><option value="ban">封禁</option><option value="mute">禁言</option><option value="warn">警告</option></select></div></div> <div class="grid-2"><div class="form-group"><label>来源</label><select id="ban-source"><option value="">未知</option></select></div><div class="form-group"><label>类型</label><select id="ban-type" onchange="BansPage.checkMinDuration()"><option value="ban">封禁</option><option value="mute">禁言</option><option value="warn">警告</option></select></div></div>
<div class="grid-2"><div class="form-group"><label>时长</label><select id="ban-dur"><option value="">永久</option><option value="1天">1天</option><option value="3天">3天</option><option value="7天">7天</option><option value="30天">30天</option></select></div></div> <div class="grid-2"><div class="form-group"><label>时长</label><select id="ban-dur"><option value="">永久</option><option value="1天">1天</option><option value="3天">3天</option><option value="7天">7天</option><option value="30天">30天</option></select></div></div>
<div class="form-group"><label>原因</label><textarea id="ban-reason" rows="2"></textarea></div> <div class="form-group"><label>原因</label><textarea id="ban-reason" rows="2"></textarea></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>
`); `);
U.sources().then(list => {
const sel = document.getElementById('ban-source');
if (sel) sel.innerHTML = '<option value="">未知</option>' + list.map(s => `<option value="${U.esc(s.code)}">${U.esc(s.label)}</option>`).join('');
}).catch(()=>{});
document.getElementById('ban-form').onsubmit = async e => { document.getElementById('ban-form').onsubmit = async e => {
e.preventDefault(); e.preventDefault();
try { try {
@@ -80,12 +84,16 @@ const BansPage = {
U.modal('编辑封禁', ` U.modal('编辑封禁', `
<form id="be-form"> <form id="be-form">
<div class="grid-2"><div class="form-group"><label>玩家名</label><input id="be-name" value="${name}"></div><div class="form-group"><label>UID</label><input id="be-uid" value="${uid}"></div></div> <div class="grid-2"><div class="form-group"><label>玩家名</label><input id="be-name" value="${name}"></div><div class="form-group"><label>UID</label><input id="be-uid" value="${uid}"></div></div>
<div class="grid-2"><div class="form-group"><label>来源</label><select id="be-source"><option value="">未知</option><option value="netease" ${source==='netease'?'selected':''}>网易端</option><option value="skin" ${source==='skin'?'selected':''}>皮肤站</option></select></div><div class="form-group"><label>状态</label><select id="be-status"><option value="active" ${status==='active'?'selected':''}>生效中</option><option value="lifted" ${status==='lifted'?'selected':''}>已解除</option><option value="expired" ${status==='expired'?'selected':''}>已过期</option></select></div></div> <div class="grid-2"><div class="form-group"><label>来源</label><select id="be-source"><option value="">未知</option></select></div><div class="form-group"><label>状态</label><select id="be-status"><option value="active" ${status==='active'?'selected':''}>生效中</option><option value="lifted" ${status==='lifted'?'selected':''}>已解除</option><option value="expired" ${status==='expired'?'selected':''}>已过期</option></select></div></div>
<div class="grid-2"><div class="form-group"><label>时长</label><input id="be-dur" value="${duration}"></div></div> <div class="grid-2"><div class="form-group"><label>时长</label><input id="be-dur" value="${duration}"></div></div>
<div class="form-group"><label>原因</label><textarea id="be-reason" rows="2">${reason}</textarea></div> <div class="form-group"><label>原因</label><textarea id="be-reason" rows="2">${reason}</textarea></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>
`); `);
U.sources().then(list => {
const sel = document.getElementById('be-source');
if (sel) sel.innerHTML = '<option value="">未知</option>' + list.map(s => `<option value="${U.esc(s.code)}" ${s.code===source?'selected':''}>${U.esc(s.label)}</option>`).join('');
}).catch(()=>{});
document.getElementById('be-form').onsubmit = async e => { document.getElementById('be-form').onsubmit = async e => {
e.preventDefault(); e.preventDefault();
try { try {

View File

@@ -18,6 +18,14 @@ const Dashboard = {
const bans = Auth.isAdmin() ? await API.get('/bans/active').catch(()=>[]) : []; const bans = Auth.isAdmin() ? await API.get('/bans/active').catch(()=>[]) : [];
const identities = await API.get('/auth/identities').catch(()=>[]); const identities = await API.get('/auth/identities').catch(()=>[]);
this.renderContent(ct, stats, exports, bans, identities); 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 = `<div class="alert alert-e">${e.message}</div>`; } } catch (e) { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
}, },
@@ -42,12 +50,12 @@ const Dashboard = {
<div class="card"><div class="card-h"><i class="fas fa-id-card"></i> 我的身份 ${idents.length < 2 ? `<button class="btn btn-p btn-sm" onclick="Dashboard.showAddIdentity()">绑定${idents.length === 1 ? '另一来源' : '身份'}</button>` : ''}</div> <div class="card"><div class="card-h"><i class="fas fa-id-card"></i> 我的身份 ${idents.length < 2 ? `<button class="btn btn-p btn-sm" onclick="Dashboard.showAddIdentity()">绑定${idents.length === 1 ? '另一来源' : '身份'}</button>` : ''}</div>
<div class="card-b"> <div class="card-b">
${idents.length === 0 ? '<span class="tm">暂无身份,点击右上角绑定</span>' : `<table><thead><tr><th>来源</th><th>游戏名</th><th>UID</th><th>绑定时间</th><th>操作</th></tr></thead><tbody>${idents.map(it=>`<tr> ${idents.length === 0 ? '<span class="tm">暂无身份,点击右上角绑定</span>' : `<table><thead><tr><th>来源</th><th>游戏名</th><th>UID</th><th>绑定时间</th><th>操作</th></tr></thead><tbody>${idents.map(it=>`<tr>
<td><span class="badge bg-${it.source==='netease'?'processing':'admin'}">${it.source==='netease'?'网易端':'皮肤站'}</span></td> <td><span class="badge bg-admin" data-src="${U.esc(it.source)}">${U.esc(it.source||'-')}</span></td>
<td>${U.esc(it.game_name)}</td><td class="ts">${U.esc(it.game_uid||'-')}</td> <td>${U.esc(it.game_name)}</td><td class="ts">${U.esc(it.game_uid||'-')}</td>
<td class="ts">${U.date(it.created_at)}</td> <td class="ts">${U.date(it.created_at)}</td>
<td>${idents.length > 1 ? `<button class="btn btn-d btn-sm" onclick="Dashboard.removeIdentity(${it.id})"><i class="fas fa-unlink"></i> 解绑</button>` : '<span class="tm ts">主身份</span>'}</td> <td>${idents.length > 1 ? `<button class="btn btn-d btn-sm" onclick="Dashboard.removeIdentity(${it.id})"><i class="fas fa-unlink"></i> 解绑</button>` : '<span class="tm ts">主身份</span>'}</td>
</tr>`).join('')}</tbody></table>`} </tr>`).join('')}</tbody></table>`}
<p class="tm ts mt-4"><i class="fas fa-info-circle"></i> 同一账号可同时绑定网易端与皮肤站身份,提交工单时选择使用哪个身份。</p> <p class="tm ts mt-4"><i class="fas fa-info-circle"></i> 同一账号可绑定多个来源身份,提交工单时选择使用哪个身份。</p>
</div> </div>
</div> </div>
@@ -71,12 +79,16 @@ const Dashboard = {
U.modal('添加封禁', ` U.modal('添加封禁', `
<form id="ban-form"> <form id="ban-form">
<div class="grid-2"><div class="form-group"><label>玩家名 *</label><input id="ban-name" required></div><div class="form-group"><label>UID</label><input id="ban-uid"></div></div> <div class="grid-2"><div class="form-group"><label>玩家名 *</label><input id="ban-name" required></div><div class="form-group"><label>UID</label><input id="ban-uid"></div></div>
<div class="grid-2"><div class="form-group"><label>来源</label><select id="ban-source"><option value="">未知</option><option value="netease">网易端</option><option value="skin">皮肤站</option></select></div><div class="form-group"><label>类型</label><select id="ban-type" onchange="Dashboard.checkMinDuration()"><option value="ban">封禁</option><option value="mute">禁言</option><option value="warn">警告</option></select></div></div> <div class="grid-2"><div class="form-group"><label>来源</label><select id="ban-source"><option value="">未知</option></select></div><div class="form-group"><label>类型</label><select id="ban-type" onchange="Dashboard.checkMinDuration()"><option value="ban">封禁</option><option value="mute">禁言</option><option value="warn">警告</option></select></div></div>
<div class="grid-2"><div class="form-group"><label>时长</label><select id="ban-dur"><option value="">永久</option><option value="3天">3天</option><option value="7天">7天</option><option value="30天">30天</option></select></div></div> <div class="grid-2"><div class="form-group"><label>时长</label><select id="ban-dur"><option value="">永久</option><option value="3天">3天</option><option value="7天">7天</option><option value="30天">30天</option></select></div></div>
<div class="form-group"><label>原因</label><textarea id="ban-reason" rows="2"></textarea></div> <div class="form-group"><label>原因</label><textarea id="ban-reason" rows="2"></textarea></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>
`); `);
U.sources().then(list => {
const sel = document.getElementById('ban-source');
if (sel) sel.innerHTML = '<option value="">未知</option>' + list.map(s => `<option value="${U.esc(s.code)}">${U.esc(s.label)}</option>`).join('');
}).catch(()=>{});
document.getElementById('ban-form').onsubmit = async e => { document.getElementById('ban-form').onsubmit = async e => {
e.preventDefault(); e.preventDefault();
try { try {
@@ -90,9 +102,9 @@ const Dashboard = {
showAddIdentity() { showAddIdentity() {
U.modal('绑定身份', ` U.modal('绑定身份', `
<form id="ident-form"> <form id="ident-form">
<div class="form-group"><label>来源</label><select id="ident-source"><option value="netease">网易端</option><option value="skin">皮肤站</option></select></div> <div class="form-group"><label>来源</label><select id="ident-source"><option value="">加载中...</option></select></div>
<div class="form-group"><label>游戏名 *</label><input id="ident-name" required placeholder="Minecraft ID"></div> <div class="form-group"><label>游戏名 *</label><input id="ident-name" required placeholder="Minecraft ID"></div>
<div class="form-group" id="ident-uid-group"><label>网易UID</label><input id="ident-uid" placeholder="仅网易端必填"></div> <div class="form-group" id="ident-uid-group" style="display:none"><label>UID(可选)</label><input id="ident-uid" placeholder="填"></div>
<div class="form-group hidden" id="ident-skin-group"> <div class="form-group hidden" id="ident-skin-group">
<label>皮肤站地址</label> <label>皮肤站地址</label>
<div style="display:flex;gap:6px"> <div style="display:flex;gap:6px">
@@ -105,14 +117,21 @@ const Dashboard = {
<div class="modal-f"><button type="button" class="btn btn-o" onclick="App.closeModal()">取消</button><button type="submit" class="btn btn-p">绑定</button></div> <div class="modal-f"><button type="button" class="btn btn-o" onclick="App.closeModal()">取消</button><button type="submit" class="btn btn-p">绑定</button></div>
</form> </form>
`); `);
// 动态加载来源
U.sources().then(async list => {
const sel = document.getElementById('ident-source');
sel.innerHTML = list.length
? list.map(s => `<option value="${U.esc(s.code)}">${U.esc(s.label)}</option>`).join('')
: '<option value="">不指定</option>';
toggleSource();
}).catch(()=>{});
const toggleSource = () => { const toggleSource = () => {
const isNetease = document.getElementById('ident-source').value === 'netease'; const src = document.getElementById('ident-source').value;
document.getElementById('ident-uid-group').style.display = isNetease ? '' : 'none'; const isSkin = src === 'skin';
document.getElementById('ident-uid').required = isNetease; document.getElementById('ident-uid-group').style.display = isSkin ? 'none' : '';
document.getElementById('ident-skin-group').classList.toggle('hidden', isNetease); document.getElementById('ident-skin-group').classList.toggle('hidden', !isSkin);
}; };
document.getElementById('ident-source').onchange = toggleSource; document.getElementById('ident-source').onchange = toggleSource;
toggleSource();
// 自动带出站点配置的皮肤站地址 // 自动带出站点配置的皮肤站地址
API.get('/settings/skin-site').then(r => { API.get('/settings/skin-site').then(r => {
if (r.site && !document.getElementById('ident-site').value) document.getElementById('ident-site').value = r.site; if (r.site && !document.getElementById('ident-site').value) document.getElementById('ident-site').value = r.site;

View File

@@ -25,10 +25,12 @@ const ExternalApiPage = {
<button class="btn btn-d btn-sm" onclick="ExternalApiPage.del(${c.id})"><i class="fas fa-trash"></i></button> <button class="btn btn-d btn-sm" onclick="ExternalApiPage.del(${c.id})"><i class="fas fa-trash"></i></button>
</td> </td>
</tr>`).join('')}</tbody></table></div>`} </tr>`).join('')}</tbody></table></div>`}
<div class="alert alert-i" style="margin-top:12px"><b>鉴权方式(唯一):</b> 请求头 <code>x-api-client-id</code> + <code>x-api-secret</code>。无凭据或凭据错误一律返回 401;secret 仅创建时显示一次,请立即保存。支持多个客户端,可单独停用/删除。</div> <div class="alert alert-i" style="margin-top:12px"><b>鉴权流程:</b> 先调 <code>POST /api/external/auth/session</code> 用 <code>x-api-client-id</code> + <code>x-api-secret</code> 换取 SESSION(24小时有效), 之后所有接口请求头用 <code>Authorization: Bearer &lt;SESSION&gt;</code>。secret 仅创建时显示一次,请立即保存。</div>
</div></div> </div></div>
<div class="card"><div class="card-h"><i class="fas fa-book"></i> 接口速览(免用户登录, 需上述鉴权头)</div><div class="card-b"><div class="table-wrap"><table><thead><tr><th>方法</th><th>路径</th><th>说明</th></tr></thead><tbody> <div class="card"><div class="card-h"><i class="fas fa-book"></i> 接口速览(除换取SESSION外, 均需 Bearer SESSION)</div><div class="card-b"><div class="table-wrap"><table><thead><tr><th>方法</th><th>路径</th><th>说明</th></tr></thead><tbody>
<tr><td>POST</td><td><code>/api/external/auth/session</code></td><td>ID+Secret 换取 SESSION(唯一使用 ID+Secret 的接口)</td></tr>
<tr><td>POST</td><td><code>/api/external/auth/register</code></td><td>插件注册账号(需 SESSION)</td></tr>
<tr><td>POST</td><td><code>/api/external/tickets</code></td><td>提交工单(举报/建议/申诉),返回 id + tracking_token;可带 <code>server</code>(别名或「分组/子服」)</td></tr> <tr><td>POST</td><td><code>/api/external/tickets</code></td><td>提交工单(举报/建议/申诉),返回 id + tracking_token;可带 <code>server</code>(别名或「分组/子服」)</td></tr>
<tr><td>GET</td><td><code>/api/external/tickets/track?token=xxx</code></td><td>按追踪码查工单状态 + 回复,从提交到结束全程可查</td></tr> <tr><td>GET</td><td><code>/api/external/tickets/track?token=xxx</code></td><td>按追踪码查工单状态 + 回复,从提交到结束全程可查</td></tr>
<tr><td>GET</td><td><code>/api/external/all-tickets?server=&status=&type=&page=&limit=</code></td><td>工单列表(可按子服过滤)</td></tr> <tr><td>GET</td><td><code>/api/external/all-tickets?server=&status=&type=&page=&limit=</code></td><td>工单列表(可按子服过滤)</td></tr>

View File

@@ -2,6 +2,17 @@ const RegisterPage = {
captcha: null, captcha: null,
codeId: null, codeId: null,
async mount() {
// 动态加载来源下拉(不设默认来源)
U.sources().then(list => {
const sel = document.getElementById('rs');
if (sel) {
sel.innerHTML = '<option value="">不指定</option>' + list.map(s => `<option value="${U.esc(s.code)}">${U.esc(s.label)}</option>`).join('');
this.toggleSource();
}
}).catch(()=>{});
},
async render() { async render() {
try { this.captcha = await API.get('/captcha'); } catch { this.captcha = null; } try { this.captcha = await API.get('/captcha'); } catch { this.captcha = null; }
return ` return `
@@ -15,11 +26,11 @@ const RegisterPage = {
</div> </div>
<div class="grid-2"> <div class="grid-2">
<div class="form-group"><label>帐号来源</label><select id="rs" onchange="RegisterPage.toggleSource()"><option value="netease">网易端</option><option value="skin">皮肤站</option></select></div> <div class="form-group"><label>帐号来源</label><select id="rs" onchange="RegisterPage.toggleSource()"><option value="">加载中...</option></select></div>
<div class="form-group"><label id="rs-label1">游戏名称</label><input id="rgn" required placeholder="Minecraft ID"></div> <div class="form-group"><label id="rs-label1">游戏名称</label><input id="rgn" required placeholder="Minecraft ID"></div>
</div> </div>
<div class="form-group" id="uid-group"><label id="rs-label2">网易UID</label><input id="rgu" placeholder="仅网易端必填"></div> <div class="form-group" id="uid-group" style="display:none"><label id="rs-label2">UID(可选)</label><input id="rgu" placeholder="填"></div>
${this.captcha ? ` ${this.captcha ? `
<div class="form-group"><label>人机验证</label> <div class="form-group"><label>人机验证</label>
@@ -55,9 +66,10 @@ const RegisterPage = {
toggleSource() { toggleSource() {
const src = document.getElementById('rs').value; const src = document.getElementById('rs').value;
document.getElementById('rs-label1').textContent = '游戏名称'; document.getElementById('rs-label1').textContent = '游戏名称';
document.getElementById('rs-label2').textContent = src === 'netease' ? '网易UID' : '皮肤站UUID(自动获取)'; const isSkin = src === 'skin';
document.getElementById('uid-group').style.display = src === 'netease' ? '' : 'none'; document.getElementById('rs-label2').textContent = isSkin ? '皮肤站UUID(可选)' : 'UID(可选)';
document.getElementById('rgu').required = src === 'netease'; document.getElementById('uid-group').style.display = isSkin ? 'none' : '';
document.getElementById('rgu').required = false;
}, },
async refreshCaptcha() { async refreshCaptcha() {

View File

@@ -0,0 +1,85 @@
const SourcesPage = {
async render() { return '<div class="loading"><i class="fas fa-spinner"></i></div>'; },
async mount() {
document.getElementById('page-title').textContent = '来源管理';
document.getElementById('page-actions').innerHTML = '<button class="btn btn-p btn-sm" onclick="SourcesPage.showAdd()"><i class="fas fa-plus"></i> 添加来源</button>';
await this.load();
},
async load() {
const ct = document.getElementById('page-content');
try {
// 管理视图: 含停用项
const rows = await API.get('/sources');
ct.innerHTML = rows.length === 0
? '<div class="empty"><i class="fas fa-tag"></i><p>暂无来源</p></div>'
: `<div class="card table-wrap"><table><thead><tr><th>code</th><th>名称</th><th>排序</th><th>状态</th><th>操作</th></tr></thead><tbody>${rows.map(s => `
<tr>
<td><code>${U.esc(s.code)}</code></td>
<td>${U.esc(s.label)}</td>
<td>${s.sort_order}</td>
<td>${s.enabled ? '<span class="badge bg-resolved">启用</span>' : '<span class="badge bg-rejected">停用</span>'}</td>
<td>
<button class="btn btn-o btn-sm" onclick="SourcesPage.showEdit('${U.escJs(s.code)}','${U.escJs(s.label)}',${s.enabled},${s.sort_order})"><i class="fas fa-edit"></i> 编辑</button>
<button class="btn btn-d btn-sm" onclick="SourcesPage.del('${U.escJs(s.code)}')"><i class="fas fa-trash"></i></button>
</td>
</tr>`).join('')}</tbody></table></div>
<p class="help" style="margin-top:8px">来源用于标识玩家身份归属(如: 网易端/皮肤站/正版/离线)。已被身份数据使用的来源不能删除, 只能停用。</p>`;
} catch (e) { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
},
showAdd() {
U.modal('添加来源', `
<form id="src-form">
<div class="grid-2">
<div class="form-group"><label>code *</label><input id="src-code" required placeholder="如: official" pattern="[a-z0-9_]{1,20}"></div>
<div class="form-group"><label>名称 *</label><input id="src-label" required placeholder="如: 正版"></div>
</div>
<div class="form-group"><label>排序</label><input id="src-sort" type="number" value="0"></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>
`);
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('编辑来源', `
<form id="src-edit-form">
<div class="form-group"><label>code</label><input value="${U.esc(code)}" disabled></div>
<div class="grid-2">
<div class="form-group"><label>名称 *</label><input id="se-label" required value="${U.esc(label)}"></div>
<div class="form-group"><label>排序</label><input id="se-sort" type="number" value="${sortOrder}"></div>
</div>
<div class="form-group"><label>状态</label><select id="se-enabled"><option value="1" ${enabled?'selected':''}>启用</option><option value="0" ${!enabled?'selected':''}>停用</option></select></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>
`);
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);}
}
};

View File

@@ -16,7 +16,7 @@ const TicketCreate = {
this.identities = list || []; this.identities = list || [];
const sel = container.querySelector('#identity-select'); const sel = container.querySelector('#identity-select');
if (sel && this.identities.length > 1) { if (sel && this.identities.length > 1) {
sel.innerHTML = this.identities.map((it, i) => `<option value="${i}">${it.source === 'netease' ? '网易端' : '皮肤站'}${U.esc(it.game_name)}${it.game_uid ? ' (UID: ' + U.esc(it.game_uid) + ')' : ''}</option>`).join(''); sel.innerHTML = this.identities.map((it, i) => `<option value="${i}">${U.esc(it.source)}${U.esc(it.game_name)}${it.game_uid ? ' (UID: ' + U.esc(it.game_uid) + ')' : ''}</option>`).join('');
sel.closest('.form-group').classList.remove('hidden'); sel.closest('.form-group').classList.remove('hidden');
} }
}).catch(() => {}); }).catch(() => {});
@@ -58,8 +58,8 @@ const TicketCreate = {
<input name="reporter_game_name" required placeholder="Minecraft ID" value="${U.esc(prefill?.game_name || user?.game_name || '')}" ${prefill?.game_name ? 'readonly' : ''}> <input name="reporter_game_name" required placeholder="Minecraft ID" value="${U.esc(prefill?.game_name || user?.game_name || '')}" ${prefill?.game_name ? 'readonly' : ''}>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>您的游戏UID *</label> <label>您的游戏UID (可选)</label>
<input name="reporter_game_uid" required placeholder="游戏UID" value="${U.esc(prefill?.game_uid || user?.game_uid || '')}" ${prefill?.game_uid ? 'readonly' : ''}> <input name="reporter_game_uid" placeholder="游戏UID(选填)" value="${U.esc(prefill?.game_uid || user?.game_uid || '')}" ${prefill?.game_uid ? 'readonly' : ''}>
</div> </div>
</div> </div>

View File

@@ -11,7 +11,6 @@ const UsersPage = {
const ct = document.getElementById('page-content'); const ct = document.getElementById('page-content');
try { try {
const users = await API.get('/users'); const users = await API.get('/users');
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>游戏名</th><th>游戏UID</th><th>来源</th><th>身份</th><th>角色</th><th>状态</th><th>时间</th><th>操作</th>
@@ -19,14 +18,21 @@ const UsersPage = {
<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><span class="badge bg-admin" data-src="${U.esc(u.source||'')}">${U.esc(u.source||'-')}</span></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.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>
<td><button class="btn btn-o btn-sm" data-action="UsersPage:editUser:${u.id}:${U.escJs(u.username)}:${U.escJs(u.email)}:${U.escJs(u.game_name)}:${U.escJs(u.game_uid)}:${u.role}:${u.active}:${u.source||'netease'}"><i class="fas fa-edit"></i></button></td> <td><button class="btn btn-o btn-sm" data-action="UsersPage:editUser:${u.id}:${U.escJs(u.username)}:${U.escJs(u.email)}:${U.escJs(u.game_name)}:${U.escJs(u.game_uid)}:${u.role}:${u.active}:${u.source||''}"><i class="fas fa-edit"></i></button></td>
</tr>`).join('')}</tbody></table></div> </tr>`).join('')}</tbody></table></div>
`; `;
// 来源 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 = `<div class="alert alert-e">${e.message}</div>`; } } catch (e) { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
}, },
@@ -44,13 +50,18 @@ const UsersPage = {
U.modal('添加用户', ` U.modal('添加用户', `
<form id="cu-form"> <form id="cu-form">
<div class="grid-2"><div class="form-group"><label>用户名 *</label><input id="cu-u" required></div><div class="form-group"><label>密码 *</label><input type="password" id="cu-p" required minlength="6"></div></div> <div class="grid-2"><div class="form-group"><label>用户名 *</label><input id="cu-u" required></div><div class="form-group"><label>密码 *</label><input type="password" id="cu-p" required minlength="6"></div></div>
<div class="grid-2"><div class="form-group"><label>邮箱 *</label><input type="email" id="cu-e" required></div><div class="form-group"><label>来源</label><select id="cu-s"><option value="netease">网易端</option><option value="skin">皮肤站</option></select></div></div> <div class="grid-2"><div class="form-group"><label>邮箱 *</label><input type="email" id="cu-e" required></div><div class="form-group"><label>来源</label><select id="cu-s"><option value="">不指定</option></select></div></div>
<div class="grid-2"><div class="form-group"><label>角色 *</label><select id="cu-r" onchange="UsersPage.toggleCreateServers()">${Auth.isOwner()?'<option value="owner">服主</option>':''}<option value="admin">管理员</option><option value="player">玩家</option></select></div></div> <div class="grid-2"><div class="form-group"><label>角色 *</label><select id="cu-r" onchange="UsersPage.toggleCreateServers()">${Auth.isOwner()?'<option value="owner">服主</option>':''}<option value="admin">管理员</option><option value="player">玩家</option></select></div></div>
<div class="grid-2"><div class="form-group"><label>游戏名 *</label><input id="cu-gn" required></div><div class="form-group"><label>游戏UID *</label><input id="cu-gu" required></div></div> <div class="grid-2"><div class="form-group"><label>游戏名 *</label><input id="cu-gn" required></div><div class="form-group"><label>游戏UID(可选)</label><input id="cu-gu" placeholder="选填"></div></div>
<div class="form-group hidden" id="cu-servers"><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 hidden" id="cu-servers"><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="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>
`); `);
// 动态加载来源下拉
U.sources().then(list => {
const sel = document.getElementById('cu-s');
if (sel) sel.innerHTML = '<option value="">不指定</option>' + list.map(s => `<option value="${U.esc(s.code)}">${U.esc(s.label)}</option>`).join('');
}).catch(()=>{});
document.getElementById('cu-form').onsubmit = async e => { document.getElementById('cu-form').onsubmit = async e => {
e.preventDefault(); e.preventDefault();
const servers = []; const servers = [];
@@ -93,7 +104,7 @@ const UsersPage = {
U.modal('编辑用户', ` U.modal('编辑用户', `
<form id="eu-form"> <form id="eu-form">
<div class="form-group"><label>用户名</label><input value="${username}" disabled style="background:var(--g100)"></div> <div class="form-group"><label>用户名</label><input value="${username}" disabled style="background:var(--g100)"></div>
<div class="grid-2"><div class="form-group"><label>邮箱</label><input type="email" id="eu-e" value="${email}"></div><div class="form-group"><label>来源</label><select id="eu-s"><option value="netease" ${source==='netease'?'selected':''}>网易端</option><option value="skin" ${source==='skin'?'selected':''}>皮肤站</option></select></div></div> <div class="grid-2"><div class="form-group"><label>邮箱</label><input type="email" id="eu-e" value="${email}"></div><div class="form-group"><label>来源</label><select id="eu-s"><option value="${U.esc(source||'')}" selected>${U.esc(source||'不指定')}</option></select></div></div>
<div class="grid-2"><div class="form-group"><label>角色</label><select id="eu-r">${['owner','admin','player'].map(r=>`<option value="${r}" ${r===role?'selected':''}>${U.ROLE[r]}</option>`).join('')}</select></div></div> <div class="grid-2"><div class="form-group"><label>角色</label><select id="eu-r">${['owner','admin','player'].map(r=>`<option value="${r}" ${r===role?'selected':''}>${U.ROLE[r]}</option>`).join('')}</select></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="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>
@@ -102,7 +113,7 @@ const UsersPage = {
<div style="border:1px solid var(--g200);border-radius:var(--r);padding:8px;margin-bottom:6px" id="identities-box"> <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=>` ${identities.length===0?'<span class="tm">暂无身份记录</span>':identities.map(it=>`
<div style="display:flex;align-items:center;gap:6px;padding:4px 0"> <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 class="badge bg-admin" data-src="${U.esc(it.source)}">${U.esc(it.source||'-')}</span>
<span>${U.esc(it.game_name)}${it.game_uid?' (UID: '+U.esc(it.game_uid)+')':''}</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> <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>'} ${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>'}
@@ -110,10 +121,10 @@ const UsersPage = {
</div> </div>
<div style="display:flex;gap:6px;align-items:center;flex-wrap:nowrap"> <div style="display:flex;gap:6px;align-items:center;flex-wrap:nowrap">
<select id="eu-ident-source" style="flex:0 0 92px;width:92px;min-width:92px;padding:6px 8px;border:1px solid var(--g300);border-radius:var(--r);font-size:12px;background:white"> <select id="eu-ident-source" style="flex:0 0 92px;width:92px;min-width:92px;padding:6px 8px;border:1px solid var(--g300);border-radius:var(--r);font-size:12px;background:white">
<option value="netease">网易端</option><option value="skin">皮肤站</option> <option value="">加载中...</option>
</select> </select>
<input id="eu-ident-name" placeholder="游戏名" style="flex:1 1 30%;min-width:0;width:auto;padding:6px 8px;border:1px solid var(--g300);border-radius:var(--r);font-size:12px"> <input id="eu-ident-name" placeholder="游戏名" style="flex:1 1 30%;min-width:0;width:auto;padding:6px 8px;border:1px solid var(--g300);border-radius:var(--r);font-size:12px">
<input id="eu-ident-uid" placeholder="UID(网易必填)" style="flex:1 1 30%;min-width:0;width:auto;padding:6px 8px;border:1px solid var(--g300);border-radius:var(--r);font-size:12px"> <input id="eu-ident-uid" placeholder="UID(可选)" style="flex:1 1 30%;min-width:0;width:auto;padding:6px 8px;border:1px solid var(--g300);border-radius:var(--r);font-size:12px">
<button type="button" class="btn btn-o btn-sm" id="eu-ident-fetch" style="flex:0 0 auto;white-space:nowrap" title="皮肤站用户名查UUID"><i class="fas fa-magnifying-glass"></i></button> <button type="button" class="btn btn-o btn-sm" id="eu-ident-fetch" style="flex:0 0 auto;white-space:nowrap" title="皮肤站用户名查UUID"><i class="fas fa-magnifying-glass"></i></button>
<button type="button" class="btn btn-p btn-sm" style="flex:0 0 auto;white-space:nowrap" onclick="UsersPage.addIdentity(${id})"><i class="fas fa-plus"></i> 绑定</button> <button type="button" class="btn btn-p btn-sm" style="flex:0 0 auto;white-space:nowrap" onclick="UsersPage.addIdentity(${id})"><i class="fas fa-plus"></i> 绑定</button>
</div> </div>
@@ -139,9 +150,31 @@ const UsersPage = {
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); }
}; };
// 动态加载来源(主身份下拉 + 身份绑定下拉), 并替换 data-src 标签
U.sources().then(list => {
const opts = list.map(s => `<option value="${U.esc(s.code)}">${U.esc(s.label)}</option>`).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 查询辅助 // 来源切换: 皮肤站时显示 UUID 查询辅助
const euToggleSource = () => { 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-site-group').classList.toggle('hidden', !isSkin);
}; };
document.getElementById('eu-ident-source').onchange = euToggleSource; document.getElementById('eu-ident-source').onchange = euToggleSource;

View File

@@ -30,6 +30,36 @@ const U = {
return String(s).replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/"/g,'&quot;').replace(/\n/g,'\\n'); return String(s).replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/"/g,'&quot;').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 ? '<option value="">不指定</option>' : '') +
list.map(s => `<option value="${this.esc(s.code)}" ${s.code===selected?'selected':''}>${this.esc(s.label)}</option>`).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 `<span class="badge bg-admin">${this.esc(hit ? hit.label : code)}</span>`;
},
showAlert(containerId, type, msg) { showAlert(containerId, type, msg) {
const ct = document.getElementById(containerId || 'page-content'); const ct = document.getElementById(containerId || 'page-content');
if (!ct) return; if (!ct) return;