mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 06:41:27 +02:00
fix admin panel and credits package
This commit is contained in:
+10
-10
@@ -653,20 +653,20 @@
|
||||
return Math.floor(sec / 3600) + 'h ' + Math.floor((sec % 3600) / 60) + 'm';
|
||||
};
|
||||
|
||||
return \`
|
||||
<tr style="\${rowStyle}">
|
||||
<td>\${entry.position}</td>
|
||||
<td title="\${entry.serverId}">\${entry.serverId.substring(0, 12)}...</td>
|
||||
<td style="\${isStuck ? 'color:#f80; font-weight:bold;' : ''}">\${formatWait(entry.waitingSeconds)}</td>
|
||||
<td>\${entry.paymentId || '-'}</td>
|
||||
<td>\${statusText}</td>
|
||||
return `
|
||||
<tr style="${rowStyle}">
|
||||
<td>${entry.position}</td>
|
||||
<td title="${entry.serverId}">${entry.serverId.substring(0, 12)}...</td>
|
||||
<td style="${isStuck ? 'color:#f80; font-weight:bold;' : ''}">${formatWait(entry.waitingSeconds)}</td>
|
||||
<td>${entry.paymentId || '-'}</td>
|
||||
<td>${statusText}</td>
|
||||
<td>
|
||||
<button class="btn btn-danger" onclick="removeQueueEntry('\${entry.serverId}')" style="background:#500; color:#f88; border:1px solid #f55; padding:3px 8px; cursor:pointer;">
|
||||
<button class="btn btn-danger" onclick="removeQueueEntry('${entry.serverId}')" style="background:#500; color:#f88; border:1px solid #f55; padding:3px 8px; cursor:pointer;">
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
\`;
|
||||
`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
console.error('Failed to load queue:', e);
|
||||
@@ -679,7 +679,7 @@
|
||||
}
|
||||
|
||||
async function removeQueueEntry(serverId) {
|
||||
if (!confirm(\`Remove queue entry for \${serverId.substring(0, 12)}...? This will cancel their queue position.\`)) return;
|
||||
if (!confirm(`Remove queue entry for ${serverId.substring(0, 12)}...? This will cancel their queue position.`)) return;
|
||||
|
||||
const reason = prompt('Reason for removal (optional):') || 'Admin removal';
|
||||
|
||||
|
||||
@@ -1331,7 +1331,7 @@ const SocketHandlers = {
|
||||
animation: earlyEntryPulse 2s infinite;
|
||||
">⚡ ENTER NOW (RISKY!) ⚡</button>
|
||||
`);
|
||||
|
||||
|
||||
// Add CSS animation
|
||||
if (!$('#earlyEntryStyles').length) {
|
||||
$('head').append(`
|
||||
@@ -1365,22 +1365,39 @@ const SocketHandlers = {
|
||||
|
||||
// Show/hide based on whether we're queued and early entry is allowed
|
||||
if (this._isQueued && this.isEarlyEntryAllowed()) {
|
||||
// Update button text to show credit cost when in paid mode
|
||||
var mode = this._gameMode;
|
||||
if (mode === 'PAID_CREDITS' || mode === 'MIXED') {
|
||||
var cost = this._creditsPerGame || 1;
|
||||
$btn.text('⚡ ENTER NOW (' + cost + ' credit, RISKY!) ⚡');
|
||||
} else {
|
||||
$btn.text('⚡ ENTER NOW (RISKY!) ⚡');
|
||||
}
|
||||
$btn.show();
|
||||
} else {
|
||||
$btn.hide();
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Request early entry from the server
|
||||
*/
|
||||
requestEarlyEntry: function() {
|
||||
if (!window.socket) return;
|
||||
|
||||
|
||||
// Confirm before spending credits
|
||||
var mode = this._gameMode;
|
||||
var msg = 'Enter the dungeon NOW?\n\nYou will die when the next block is found!';
|
||||
if (mode === 'PAID_CREDITS' || mode === 'MIXED') {
|
||||
var cost = this._creditsPerGame || 1;
|
||||
msg = 'Use ' + cost + ' credit to enter the dungeon NOW?\n\nYou will die when the next block is found!';
|
||||
}
|
||||
if (!confirm(msg)) return;
|
||||
|
||||
// Disable button to prevent double-clicks
|
||||
const $btn = $('#earlyEntryButton');
|
||||
var $btn = $('#earlyEntryButton');
|
||||
$btn.prop('disabled', true).text('⏳ Entering...');
|
||||
|
||||
|
||||
socket.emit('early_entry');
|
||||
},
|
||||
|
||||
|
||||
+123
-108
@@ -262,85 +262,91 @@ class GameModeManager {
|
||||
*/
|
||||
async processCreditsPackageConfirmation(socketId, paymentId, packageInfo = null) {
|
||||
try {
|
||||
const user = await this.getOrCreateUser(socketId);
|
||||
// Look up user from payment record's user_id (stable across socket reconnects)
|
||||
// This avoids the bug where socket ID changes if user refreshes during confirmation
|
||||
const paymentLookup = await this.db.query(`
|
||||
SELECT user_id, description, status FROM payments WHERE id = $1
|
||||
`, [paymentId]);
|
||||
|
||||
// Determine credits to add from package or fallback
|
||||
if (paymentLookup.rows.length === 0) {
|
||||
console.error(`Payment ${paymentId} not found`);
|
||||
return { success: false, reason: 'Payment not found' };
|
||||
}
|
||||
|
||||
// Fast-fail if already confirmed (avoids unnecessary transaction)
|
||||
if (paymentLookup.rows[0].status === 'confirmed') {
|
||||
console.log(`Payment ${paymentId} already confirmed, skipping duplicate processing`);
|
||||
return { success: false, alreadyProcessed: true, reason: 'Payment already processed' };
|
||||
}
|
||||
|
||||
const userId = paymentLookup.rows[0].user_id;
|
||||
|
||||
// Determine credits to add from package info or payment description
|
||||
let creditsToAdd = 10; // Default fallback
|
||||
if (packageInfo && packageInfo.credits) {
|
||||
creditsToAdd = Number(packageInfo.credits) + (Number(packageInfo.bonus) || 0);
|
||||
} else {
|
||||
// Try to get from payment record description
|
||||
const paymentResult = await this.db.query(`
|
||||
SELECT description, status FROM payments WHERE id = $1
|
||||
`, [paymentId]);
|
||||
if (paymentResult.rows.length > 0) {
|
||||
// SECURITY: Check if already confirmed (prevents double-crediting)
|
||||
if (paymentResult.rows[0].status === 'confirmed') {
|
||||
console.log(`⚠️ Payment ${paymentId} already confirmed, skipping duplicate processing`);
|
||||
return {
|
||||
success: false,
|
||||
alreadyProcessed: true,
|
||||
reason: 'Payment already processed'
|
||||
};
|
||||
}
|
||||
const desc = paymentResult.rows[0].description || '';
|
||||
const match = desc.match(/(\d+)\s*credits?/i);
|
||||
if (match) {
|
||||
creditsToAdd = parseInt(match[1], 10) || 10;
|
||||
}
|
||||
const desc = paymentLookup.rows[0].description || '';
|
||||
const match = desc.match(/(\d+)\s*credits?/i);
|
||||
if (match) {
|
||||
creditsToAdd = parseInt(match[1], 10) || 10;
|
||||
}
|
||||
}
|
||||
|
||||
// SECURITY: Atomically update payment status from 'pending' to 'confirmed'
|
||||
// Only proceeds if the payment was actually pending (prevents double-crediting)
|
||||
const paymentUpdateResult = await this.db.query(`
|
||||
UPDATE payments
|
||||
SET status = 'confirmed',
|
||||
credits_purchased = $1,
|
||||
confirmed_at = NOW()
|
||||
WHERE id = $2 AND status = 'pending'
|
||||
RETURNING id
|
||||
`, [creditsToAdd, paymentId]);
|
||||
// CRITICAL: Wrap all three operations in a transaction.
|
||||
// If any step fails, the payment stays 'pending' and can be recovered.
|
||||
const result = await this.db.withTransaction(async (client) => {
|
||||
// Step 1: Atomically mark payment as confirmed (prevents double-crediting)
|
||||
const paymentUpdateResult = await client.query(`
|
||||
UPDATE payments
|
||||
SET status = 'confirmed',
|
||||
credits_purchased = $1,
|
||||
confirmed_at = NOW()
|
||||
WHERE id = $2 AND status = 'pending'
|
||||
RETURNING id
|
||||
`, [creditsToAdd, paymentId]);
|
||||
|
||||
// If no rows were updated, payment was already processed
|
||||
if (paymentUpdateResult.rows.length === 0) {
|
||||
console.log(`⚠️ Payment ${paymentId} already confirmed (atomic check), skipping duplicate`);
|
||||
return {
|
||||
success: false,
|
||||
alreadyProcessed: true,
|
||||
reason: 'Payment already processed'
|
||||
};
|
||||
if (paymentUpdateResult.rows.length === 0) {
|
||||
// Already processed by another instance - not an error
|
||||
return { alreadyProcessed: true };
|
||||
}
|
||||
|
||||
// Step 2: Add credits to user (using payment's user_id, not socket lookup)
|
||||
const updateResult = await client.query(`
|
||||
UPDATE users
|
||||
SET credits = credits + $1,
|
||||
total_credits_purchased = COALESCE(total_credits_purchased, 0) + $1,
|
||||
updated_at = NOW()
|
||||
WHERE id = $2
|
||||
RETURNING credits
|
||||
`, [creditsToAdd, userId]);
|
||||
|
||||
const newBalance = updateResult.rows[0]?.credits ?? creditsToAdd;
|
||||
|
||||
// Step 3: Record credit transaction for audit trail
|
||||
await client.query(`
|
||||
INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type)
|
||||
VALUES ($1, $2, 'package_purchase', $3, 'purchase')
|
||||
`, [userId, creditsToAdd, newBalance]);
|
||||
|
||||
return { newBalance };
|
||||
});
|
||||
|
||||
if (result.alreadyProcessed) {
|
||||
console.log(`Payment ${paymentId} already confirmed (atomic check), skipping duplicate`);
|
||||
return { success: false, alreadyProcessed: true, reason: 'Payment already processed' };
|
||||
}
|
||||
|
||||
// Payment successfully marked as confirmed - now add credits
|
||||
// Use atomic increment to prevent race conditions
|
||||
const updateResult = await this.db.query(`
|
||||
UPDATE users
|
||||
SET credits = credits + $1,
|
||||
total_credits_purchased = COALESCE(total_credits_purchased, 0) + $1,
|
||||
updated_at = NOW()
|
||||
WHERE id = $2
|
||||
RETURNING credits
|
||||
`, [creditsToAdd, user.id]);
|
||||
|
||||
const newBalance = updateResult.rows[0]?.credits ?? (user.credits + creditsToAdd);
|
||||
|
||||
// Record credit transaction
|
||||
await this.db.query(`
|
||||
INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type)
|
||||
VALUES ($1, $2, 'package_purchase', $3, 'purchase')
|
||||
`, [user.id, creditsToAdd, newBalance]);
|
||||
|
||||
console.log(`💰 Credits package confirmed: +${creditsToAdd} credits for user ${user.id}, new balance: ${newBalance}`);
|
||||
console.log(`Credits package confirmed: +${creditsToAdd} credits for user ${userId}, new balance: ${result.newBalance}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
creditsAdded: creditsToAdd,
|
||||
newBalance: newBalance
|
||||
newBalance: result.newBalance
|
||||
};
|
||||
} catch (error) {
|
||||
const normalized = normalizeError(error, 'Failed to process credits package confirmation');
|
||||
console.error('❌ Error processing credits package:', normalized.message);
|
||||
console.error('Error processing credits package:', normalized.message);
|
||||
return {
|
||||
success: false,
|
||||
reason: normalized.safeMessage
|
||||
@@ -722,65 +728,74 @@ class GameModeManager {
|
||||
|
||||
async _processGameStartWithCredits(user, socketId, gameId) {
|
||||
const creditsToSpend = this.creditsPerGameCost;
|
||||
// Use a conditional update to prevent credits going negative
|
||||
const updateRes = await this.db.query(`
|
||||
UPDATE users
|
||||
SET credits = credits - $1,
|
||||
total_games_played = total_games_played + 1,
|
||||
updated_at = NOW()
|
||||
WHERE id = $2 AND credits >= $1
|
||||
RETURNING credits
|
||||
`, [creditsToSpend, user.id]);
|
||||
|
||||
// If no rows were updated, user doesn't have enough credits
|
||||
if (updateRes.rows.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
|
||||
const result = await this.db.withTransaction(async (client) => {
|
||||
// Conditional update prevents credits going negative
|
||||
const updateRes = await client.query(`
|
||||
UPDATE users
|
||||
SET credits = credits - $1,
|
||||
total_games_played = total_games_played + 1,
|
||||
updated_at = NOW()
|
||||
WHERE id = $2 AND credits >= $1
|
||||
RETURNING credits
|
||||
`, [creditsToSpend, user.id]);
|
||||
|
||||
if (updateRes.rows.length === 0) {
|
||||
return { insufficient: true };
|
||||
}
|
||||
|
||||
const remainingCredits = updateRes.rows[0].credits;
|
||||
|
||||
await client.query(`
|
||||
UPDATE games SET payment_mode = 'credits' WHERE id = $1
|
||||
`, [gameId]);
|
||||
|
||||
await client.query(`
|
||||
INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type)
|
||||
VALUES ($1, $2, 'game_entry', $3, 'spend')
|
||||
`, [user.id, -creditsToSpend, remainingCredits]);
|
||||
|
||||
return { remainingCredits };
|
||||
});
|
||||
|
||||
if (result.insufficient) {
|
||||
return {
|
||||
success: false,
|
||||
reason: 'Insufficient credits',
|
||||
creditsRequired: creditsToSpend,
|
||||
creditsAvailable: user.credits
|
||||
};
|
||||
}
|
||||
const remainingCredits = updateRes.rows[0] ? updateRes.rows[0].credits : (user.credits - creditsToSpend);
|
||||
|
||||
// Record the game mode used
|
||||
await this.db.query(`
|
||||
UPDATE games SET payment_mode = 'credits' WHERE id = $1
|
||||
`, [gameId]);
|
||||
|
||||
// Record credit transaction
|
||||
await this.db.query(`
|
||||
INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type)
|
||||
VALUES ($1, $2, 'game_entry', $3, 'spend')
|
||||
`, [user.id, -creditsToSpend, remainingCredits]);
|
||||
|
||||
console.log(`🎫 Deducted ${creditsToSpend} credit(s) from user ${user.id}, ${remainingCredits} remaining`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
creditsRemaining: remainingCredits,
|
||||
console.log(`Deducted ${creditsToSpend} credit(s) from user ${user.id}, ${result.remainingCredits} remaining`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
creditsRemaining: result.remainingCredits,
|
||||
creditsSpent: creditsToSpend,
|
||||
effectiveMode: 'PAID_CREDITS'
|
||||
};
|
||||
}
|
||||
|
||||
async _processGameStartWithPayment(user, payment, gameId) {
|
||||
await this.db.query(`
|
||||
UPDATE games
|
||||
SET payment_id = $1
|
||||
WHERE id = $2
|
||||
`, [payment.id, gameId]);
|
||||
|
||||
await this.db.query(`
|
||||
UPDATE users
|
||||
SET total_games_played = total_games_played + 1,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`, [user.id]);
|
||||
|
||||
console.log(`💳 Linked game ${gameId} to payment ${payment.id}`);
|
||||
return {
|
||||
success: true,
|
||||
await this.db.withTransaction(async (client) => {
|
||||
await client.query(`
|
||||
UPDATE games
|
||||
SET payment_id = $1
|
||||
WHERE id = $2
|
||||
`, [payment.id, gameId]);
|
||||
|
||||
await client.query(`
|
||||
UPDATE users
|
||||
SET total_games_played = total_games_played + 1,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`, [user.id]);
|
||||
});
|
||||
|
||||
console.log(`Linked game ${gameId} to payment ${payment.id}`);
|
||||
return {
|
||||
success: true,
|
||||
paymentId: payment.id,
|
||||
effectiveMode: 'PAID_SINGLE'
|
||||
};
|
||||
|
||||
@@ -61,6 +61,11 @@ app.get('/', function(req, res) {
|
||||
res.sendFile('index.html', { root: htmlPath });
|
||||
});
|
||||
|
||||
// Serve admin panel
|
||||
app.get('/admin', function(req, res) {
|
||||
res.sendFile('admin.html', { root: htmlPath });
|
||||
});
|
||||
|
||||
// Debug endpoint to receive client-side debug info
|
||||
app.post('/debug', (req, res) => {
|
||||
if (debugManager.CONSOLE_LOGGING) {
|
||||
|
||||
@@ -162,28 +162,65 @@ class SessionManager {
|
||||
AND p.status = 'confirmed'
|
||||
AND p.payment_type = 'credits_package'
|
||||
AND (p.credits_purchased IS NULL OR p.credits_purchased = 0)
|
||||
AND p.confirmed_at > NOW() - INTERVAL '24 hours'
|
||||
AND p.confirmed_at > NOW() - INTERVAL '7 days'
|
||||
ORDER BY p.confirmed_at ASC
|
||||
`, [userId]);
|
||||
|
||||
// Also find payments where credits_purchased was set but credits were never
|
||||
// actually added to the user (e.g., crash/disconnect between payment update
|
||||
// and user credit update before transaction wrapping was added)
|
||||
const orphanedPayments = await this.db.query(`
|
||||
SELECT p.id, p.credits_purchased, p.confirmed_at
|
||||
FROM payments p
|
||||
WHERE p.user_id = $1
|
||||
AND p.status = 'confirmed'
|
||||
AND p.payment_type = 'credits_package'
|
||||
AND p.credits_purchased > 0
|
||||
AND p.confirmed_at > NOW() - INTERVAL '7 days'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM credit_transactions ct
|
||||
WHERE ct.user_id = p.user_id
|
||||
AND ct.amount = p.credits_purchased
|
||||
AND ct.reason IN ('package_purchase', 'package_purchase_recovered')
|
||||
AND ct.created_at >= p.confirmed_at - INTERVAL '1 minute'
|
||||
)
|
||||
ORDER BY p.confirmed_at ASC
|
||||
`, [userId]);
|
||||
|
||||
// Merge orphaned payments into unprocessed list (they need recovery too)
|
||||
for (const orphan of orphanedPayments.rows) {
|
||||
unprocessedPayments.rows.push({
|
||||
id: orphan.id,
|
||||
payment_type: 'credits_package',
|
||||
description: null,
|
||||
expected_amount: null,
|
||||
confirmed_at: orphan.confirmed_at,
|
||||
_creditsFromRecord: orphan.credits_purchased // use stored value
|
||||
});
|
||||
}
|
||||
|
||||
if (unprocessedPayments.rows.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const payment of unprocessedPayments.rows) {
|
||||
try {
|
||||
// Parse credits from description (e.g., "Wowngeon 10 credits package (WOW)")
|
||||
let creditsToAdd = 10; // Default fallback
|
||||
const desc = payment.description || '';
|
||||
const match = desc.match(/(\d+)\s*credits?/i);
|
||||
if (match) {
|
||||
creditsToAdd = parseInt(match[1], 10) || 10;
|
||||
// Determine credits: use stored value from orphaned record, or parse from description
|
||||
let creditsToAdd = payment._creditsFromRecord || 0;
|
||||
if (!creditsToAdd) {
|
||||
creditsToAdd = 10; // Default fallback
|
||||
const desc = payment.description || '';
|
||||
const match = desc.match(/(\d+)\s*credits?/i);
|
||||
if (match) {
|
||||
creditsToAdd = parseInt(match[1], 10) || 10;
|
||||
}
|
||||
}
|
||||
const isOrphaned = !!payment._creditsFromRecord;
|
||||
|
||||
// CRITICAL: Use transaction with row lock to prevent double-credit race condition
|
||||
// This ensures only one instance can process a payment at a time
|
||||
const recovered = await this.db.withTransaction(async (client) => {
|
||||
// Lock the payment row and re-check if it's still unprocessed
|
||||
// Lock the payment row and re-check status
|
||||
const lockResult = await client.query(`
|
||||
SELECT id, credits_purchased
|
||||
FROM payments
|
||||
@@ -191,9 +228,27 @@ class SessionManager {
|
||||
FOR UPDATE
|
||||
`, [payment.id]);
|
||||
|
||||
// If already processed (by another instance), skip
|
||||
if (!lockResult.rows[0] || (lockResult.rows[0].credits_purchased && lockResult.rows[0].credits_purchased > 0)) {
|
||||
return null; // Already processed
|
||||
if (!lockResult.rows[0]) return null;
|
||||
|
||||
// For normal recovery: skip if credits_purchased already set
|
||||
// For orphaned recovery: skip if a credit_transaction already exists
|
||||
if (!isOrphaned) {
|
||||
if (lockResult.rows[0].credits_purchased && lockResult.rows[0].credits_purchased > 0) {
|
||||
return null; // Already processed
|
||||
}
|
||||
} else {
|
||||
// Double-check: does a credit_transaction already exist for this?
|
||||
const txCheck = await client.query(`
|
||||
SELECT 1 FROM credit_transactions
|
||||
WHERE user_id = $1
|
||||
AND amount = $2
|
||||
AND reason IN ('package_purchase', 'package_purchase_recovered')
|
||||
AND created_at >= $3::timestamp - INTERVAL '1 minute'
|
||||
LIMIT 1
|
||||
`, [userId, creditsToAdd, payment.confirmed_at]);
|
||||
if (txCheck.rows.length > 0) {
|
||||
return null; // Already recovered
|
||||
}
|
||||
}
|
||||
|
||||
// Add credits to user
|
||||
|
||||
Reference in New Issue
Block a user