feat: game UID fully internal + remove multi-identity copy

- game_uid is now an internal param: auto-generated server-side
  (U + 12 hex) on register/bind-identity/install/admin-create/external
  register; player-supplied game_uid ignored
- Removed all player-facing UID inputs: register page, install page,
  identity bind modal, ticket-create 'your UID' field
- Removed player-facing UID display: identity table column,
  ticket detail/tracking reporter UID
- Removed '同一账号可绑定网易端 + 皮肤站身份' / '可绑定多个来源身份' copy
- Kept target_game_uid (reported player) and admin panel UID management
- settings-page skin-site placeholder updated
This commit is contained in:
2026-08-21 21:52:40 +08:00
parent 188e083719
commit cb5847d650
11 changed files with 29 additions and 75 deletions

View File

@@ -118,13 +118,15 @@ router.post('/register', validateLengths({
} catch {}
const hashed = await bcrypt.hash(password, 10);
// 游戏UID为内置参数: 不接收玩家输入, 由系统自动生成
const builtinUid = 'U' + uuid().replace(/-/g, '').slice(0, 12).toUpperCase();
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 || '']
[username, hashed, email, game_name, builtinUid, 1, 1, source || '']
);
try {
await query('INSERT INTO user_identities(user_id, source, game_name, game_uid) VALUES (?,?,?,?)',
[r.insertId, source || '', game_name, game_uid || '']);
[r.insertId, source || '', game_name, builtinUid]);
} catch {}
res.status(201).json({ message: '注册成功,请登录' });
} catch (err) {
@@ -233,7 +235,8 @@ router.post('/identities', authenticate, async (req, res) => {
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 || '']);
// 游戏UID为内置参数: 不接收玩家输入, 由系统自动生成
[req.user.id, source || '', game_name, 'U' + uuid().replace(/-/g, '').slice(0, 12).toUpperCase()]);
res.status(201).json({ id: r.insertId, message: '绑定成功' });
} catch (err) {
console.error('[auth]', err);

View File

@@ -166,14 +166,16 @@ router.post('/auth/register', async (req, res) => {
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 || '']);
// 游戏UID为内置参数: 不接收客户端输入, 由系统自动生成
const builtinUid = 'U' + crypto.randomBytes(6).toString('hex').toUpperCase();
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, builtinUid, verifyToken, source || '']);
try {
await query('INSERT INTO user_identities(user_id, source, game_name, game_uid) VALUES (?,?,?,?)',
[r.insertId, source || '', game_name, game_uid || '']);
[r.insertId, source || '', game_name, builtinUid]);
} catch {}
const site = await getRow("SELECT v FROM settings WHERE k='site_url'");
const sent = await sendEmail(email, 'verify_email', { username, game_name, game_uid, verify_link: `${site?.v||'http://localhost:3100'}#/verify?token=${verifyToken}` });
const sent = await sendEmail(email, 'verify_email', { username, game_name, game_uid: builtinUid, verify_link: `${site?.v||'http://localhost:3100'}#/verify?token=${verifyToken}` });
if (!sent) {
await query('UPDATE users SET email_verified=1,active=1,verify_token=NULL WHERE username=?', [username]);

View File

@@ -85,9 +85,11 @@ router.post('/complete', async (req, res) => {
await initSchema(conn);
const hashed = bcrypt.hashSync(admin_password, 10);
// 游戏UID为内置参数: 不接收输入, 由系统自动生成
const builtinUid = 'U' + crypto.randomBytes(6).toString('hex').toUpperCase();
await conn.query(
'INSERT INTO users(username, password, email, game_name, game_uid, role, active, email_verified) VALUES (?,?,?,?,?,?,1,1)',
[admin_username, hashed, admin_email, admin_game_name || admin_username, admin_game_uid || 'OWNER001', 'owner']
[admin_username, hashed, admin_email, admin_game_name || admin_username, builtinUid, 'owner']
);
const jwtSecret = crypto.randomBytes(32).toString('hex');

View File

@@ -19,6 +19,7 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const { query, getRow } = require('../db');
const { authenticate, requireRole } = require('../middleware/auth');
@@ -115,10 +116,12 @@ router.post('/', authenticate, requireRole('owner','admin'), async (req, res) =>
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 || '']);
// 游戏UID为内置参数: 管理员未指定时自动生成
const builtinUid = game_uid || ('U' + crypto.randomBytes(6).toString('hex').toUpperCase());
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, builtinUid, role, source || '']);
try {
await query('INSERT INTO user_identities(user_id, source, game_name, game_uid) VALUES (?,?,?,?)',
[r.insertId, source || '', game_name, game_uid || '']);
[r.insertId, source || '', game_name, builtinUid]);
} 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: '创建成功' });

View File

@@ -68,13 +68,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-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>绑定时间</th><th>操作</th></tr></thead><tbody>${idents.map(it=>`<tr>
<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.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>
</tr>`).join('')}</tbody></table>`}
<p class="tm ts mt-4"><i class="fas fa-info-circle"></i> 同一账号可绑定多个来源身份,提交工单时选择使用哪个身份。</p>
</div>
</div>
@@ -123,15 +122,6 @@ const Dashboard = {
<form id="ident-form">
<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" 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">
<label>皮肤站地址</label>
<div style="display:flex;gap:6px">
<input id="ident-site" placeholder="https://littleskin.cn" style="flex:1">
<button type="button" class="btn btn-o btn-sm" id="ident-fetch-btn" style="flex:0 0 auto;white-space:nowrap"><i class="fas fa-magnifying-glass"></i> 获取UUID</button>
</div>
<span class="help">输入皮肤站用户名后点「获取UUID」自动填入下方UID</span>
</div>
<div id="ident-err" class="alert alert-e hidden"></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>
@@ -142,44 +132,13 @@ const Dashboard = {
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 src = document.getElementById('ident-source').value;
const isSkin = src === 'skin';
document.getElementById('ident-uid-group').style.display = isSkin ? 'none' : '';
document.getElementById('ident-skin-group').classList.toggle('hidden', !isSkin);
};
document.getElementById('ident-source').onchange = toggleSource;
// 自动带出站点配置的皮肤站地址
API.get('/settings/skin-site').then(r => {
if (r.site && !document.getElementById('ident-site').value) document.getElementById('ident-site').value = r.site;
}).catch(()=>{});
document.getElementById('ident-fetch-btn').onclick = async () => {
const site = document.getElementById('ident-site').value.trim();
const name = document.getElementById('ident-name').value.trim();
if (!site) return alert('请填写皮肤站地址');
if (!name) return alert('请先填写游戏名(皮肤站用户名)');
const btn = document.getElementById('ident-fetch-btn');
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 查询中';
try {
const r = await API.get(`/settings/uuid-lookup?site=${encodeURIComponent(site)}&name=${encodeURIComponent(name)}`);
if (r.found) {
document.getElementById('ident-uid').value = r.uuid;
alert('已获取 UUID: ' + r.uuid);
} else {
alert(r.message || '未找到该用户名');
}
} catch (ex) { alert(ex.message); }
btn.disabled = false; btn.innerHTML = '<i class="fas fa-magnifying-glass"></i> 获取UUID';
};
document.getElementById('ident-form').onsubmit = async e => {
e.preventDefault();
try {
await API.post('/auth/identities', {
source: document.getElementById('ident-source').value,
game_name: document.getElementById('ident-name').value,
game_uid: document.getElementById('ident-uid').value,
});
App.closeModal();
this.mount();

View File

@@ -65,7 +65,7 @@ const HomePage = {
API.get(`/tickets/tracking?token=${token}`).then(tickets => {
const t = tickets.find(x=>x.id===id);
if(!t) return;
document.getElementById('tkd').innerHTML = `<div class="detail-section"><h4>基本信息</h4><div class="detail-row"><span class="lbl">标题</span><span class="val fb">${U.esc(t.title)}</span></div><div class="detail-row"><span class="lbl">类型</span><span class="val">${U.badge(t.type,'type')}</span></div><div class="detail-row"><span class="lbl">状态</span><span class="val">${U.badge(t.status,'status')}</span></div><div class="detail-row"><span class="lbl">提交者</span><span class="val">${U.esc(t.reporter_game_name)} (UID: ${U.esc(t.reporter_game_uid)})</span></div>${t.description?`<div class="detail-row"><span class="lbl">详情</span><span class="val">${U.esc(t.description)}</span></div>`:''}</div>
document.getElementById('tkd').innerHTML = `<div class="detail-section"><h4>基本信息</h4><div class="detail-row"><span class="lbl">标题</span><span class="val fb">${U.esc(t.title)}</span></div><div class="detail-row"><span class="lbl">类型</span><span class="val">${U.badge(t.type,'type')}</span></div><div class="detail-row"><span class="lbl">状态</span><span class="val">${U.badge(t.status,'status')}</span></div><div class="detail-row"><span class="lbl">提交者</span><span class="val">${U.esc(t.reporter_game_name)}</span></div>${t.description?`<div class="detail-row"><span class="lbl">详情</span><span class="val">${U.esc(t.description)}</span></div>`:''}</div>
<form id="anon-reply-form" class="mt-4"><div class="form-group"><textarea id="anon-reply-content" rows="3" placeholder="追加补充信息..."></textarea></div><button type="submit" class="btn btn-p btn-sm"><i class="fas fa-reply"></i> 补充</button></form>`;
document.getElementById('anon-reply-form').onsubmit = async e => {
e.preventDefault();

View File

@@ -91,7 +91,6 @@ const InstallPage = {
<div class="form-group"><label>邮箱 *</label><input type="email" id="ia-email" required></div></div>
<div class="grid-2"><div class="form-group"><label>密码 *</label><input type="password" id="ia-password" required minlength="6"></div>
<div class="form-group"><label>游戏名称</label><input id="ia-game" placeholder="Minecraft ID"></div></div>
<div class="form-group"><label>游戏UID</label><input id="ia-uid" placeholder="游戏唯一ID"></div>
<div id="ia-err" class="alert alert-e hidden"></div>
<div id="ia-warn" class="alert alert-i"><strong>注意:</strong>安装完成后需要重启服务器才能加载业务路由。请记好账号密码。</div>
<button type="submit" class="btn btn-p btn-lg w-full"><i class="fas fa-check"></i> 完成安装</button>
@@ -114,7 +113,6 @@ const InstallPage = {
admin_password: document.getElementById('ia-password').value,
admin_email: document.getElementById('ia-email').value,
admin_game_name: document.getElementById('ia-game').value,
admin_game_uid: document.getElementById('ia-uid').value,
});
document.getElementById('install-admin').innerHTML = '<div class="alert alert-s"><h3><i class="fas fa-check-circle"></i> 安装成功!</h3><p>系统已就绪,正在刷新并跳转登录...</p></div>';
setTimeout(() => location.reload(), 1500);

View File

@@ -49,8 +49,6 @@ const RegisterPage = {
<div class="form-group"><label id="rs-label1">游戏名称</label><input id="rgn" required placeholder="Minecraft ID"></div>
</div>
<div class="form-group" id="uid-group" style="display:none"><label id="rs-label2">UID(可选)</label><input id="rgu" placeholder="选填"></div>
${this.captcha ? `
<div class="form-group"><label>人机验证</label>
<div style="display:flex;align-items:center;gap:10px">
@@ -85,10 +83,6 @@ const RegisterPage = {
toggleSource() {
const src = document.getElementById('rs').value;
document.getElementById('rs-label1').textContent = '游戏名称';
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() {
@@ -124,7 +118,7 @@ const RegisterPage = {
await Auth.register({
username: document.getElementById('ru').value.trim(), password: document.getElementById('rp').value,
email: document.getElementById('re').value.trim(), game_name: document.getElementById('rgn').value.trim(),
game_uid: document.getElementById('rgu').value.trim(), source: document.getElementById('rs').value,
source: document.getElementById('rs').value,
captcha_id: document.getElementById('captcha-id')?.value,
captcha_answer: document.getElementById('captcha-a')?.value.trim(),
code_id: this.codeId, code: document.getElementById('vf-code').value.trim(),

View File

@@ -33,7 +33,7 @@ const SettingsPage = {
<div class="form-group"><label>站点地址</label><input id="s-url" value="${U.esc(settings.site_url?.value||'')}"></div></div>
<div class="grid-2"><div class="form-group"><label>页尾版权</label><input id="s-copyright" value="${U.esc(settings.copyright?.value||'')}" placeholder="&copy; 2024 MC举报系统"></div>
<div class="form-group"><label>ICP备案号</label><input id="s-icp" value="${U.esc(settings.icp?.value||'')}" placeholder="沪ICP备XXXXXXXX号"></div></div>
<div class="form-group"><label>皮肤站地址</label><input id="s-skin-site" value="${U.esc(settings.skin_site?.value||'')}" placeholder="https://littleskin.cn用于绑定皮肤站身份时自动查询UUID"></div>
<div class="form-group"><label>皮肤站地址</label><input id="s-skin-site" value="${U.esc(settings.skin_site?.value||'')}" placeholder="https://littleskin.cn"></div>
<button type="submit" class="btn btn-p btn-sm"><i class="fas fa-save"></i> 保存站点设置</button>
</form>
</div></div>

View File

@@ -35,7 +35,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) => `<option value="${i}">${U.esc(it.source)}${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)}</option>`).join('');
sel.closest('.form-group').classList.remove('hidden');
}
}).catch(() => {});
@@ -68,19 +68,12 @@ const TicketCreate = {
${user ? `<div class="form-group hidden" id="identity-group">
<label>使用身份</label>
<select id="identity-select"></select>
<span class="help">同一账号可绑定网易端 + 皮肤站身份</span>
</div>` : ''}
<div class="grid-2">
<div class="form-group">
<label>您的游戏名称 *</label>
<input name="reporter_game_name" required placeholder="Minecraft ID" value="${U.esc(prefill?.game_name || user?.game_name || '')}" ${prefill?.game_name ? 'readonly' : ''}>
</div>
<div class="form-group">
<label>您的游戏UID (可选)</label>
<input name="reporter_game_uid" placeholder="游戏UID(选填)" value="${U.esc(prefill?.game_uid || user?.game_uid || '')}" ${prefill?.game_uid ? 'readonly' : ''}>
</div>
</div>
${isReport ? `
<div class="grid-2">

View File

@@ -42,7 +42,7 @@ const TicketDetail = {
<div class="detail-grid"><div>
<div class="card"><div class="card-h"><span>基本信息</span><div>${U.badge(t.status,'status')} ${U.badge(t.type,'type')} ${U.badge(t.priority,'priority')}</div></div><div class="card-b">
<div class="detail-row"><span class="lbl">标题</span><span class="val fb">${U.esc(t.title)}</span></div>
<div class="detail-row"><span class="lbl">提交者</span><span class="val">${U.esc(t.reporter_game_name)} (UID: ${U.esc(t.reporter_game_uid)})</span></div>
<div class="detail-row"><span class="lbl">提交者</span><span class="val">${U.esc(t.reporter_game_name)}</span></div>
${isRep?`<div class="detail-row"><span class="lbl">被举报人</span><span class="val">${U.esc(t.target_game_name||'?')} (UID: ${U.esc(t.target_game_uid||'?')})</span></div>
<div class="detail-row"><span class="lbl">原因</span><span class="val">${U.esc(t.reason)}</span></div>`:''}
${isAppeal?`<div class="detail-row"><span class="lbl">申诉理由</span><span class="val">${U.esc(t.reason)}</span></div>`:''}