Files
NightSpire/web-next/lib/trade.ts
T
Inna cccc70338d Quitar el prefijo home_ de las 41 tablas del portal
El prefijo venía del nombre de la app Django (`home`), jubilada y borrada hoy,
así que ya no significaba nada: home_stripelog -> stripelog. La BD se sigue
llamando django_wow por historia (renombrarla es otra operación).

Comprobado antes de tocar nada: 0 colisiones con nombres existentes, 0 palabras
reservadas (contrastado contra las 260 de MySQL), sin vistas ni triggers que
dependieran de ellas. El RENAME va en una sola sentencia porque así es atómico,
y MySQL reapunta solo las 4 claves ajenas entre estas tablas.

170 referencias actualizadas en 37 ficheros. Se dejó a propósito
sql/drop_home_securitytoken_authuser_fk.sql sin tocar: es una migración ya
aplicada y reescribirla sería falsear el historial.

De paso salió un fallo previo: prices.ts consultaba `home_restoreitemprice`,
una tabla que NO existe. El try/catch de priceFrom se comía el error y devolvía
siempre el precio por defecto, así que el precio de restaurar objetos nunca fue
configurable. Se deja documentado en el código; crear la tabla es otra decisión.

Verificado tras aplicar: 0 tablas home_, las 51 siguen ahí, y el conteo exacto
de filas cuadra con el volcado previo (store_item 3068, item_data 44873,
stripelog 35, store_order 26, noticia 50). La web responde 200 en todas las
rutas y la portada carga las noticias sin errores.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 14:49:19 +00:00

298 lines
12 KiB
TypeScript

import crypto from 'node:crypto'
import type { RowDataPacket, ResultSetHeader } from 'mysql2'
import { db, DB } from './db'
import { authenticate } from './auth'
import { checkSecurityToken } from './security-token'
import { transferDPoints } from './dpoints'
import { executeSoapCommand } from './soap'
/**
* Comercio de PD: códigos de 1 solo uso que valen X PD a cambio de X oro.
*
* - El vendedor crea un código (VENTA) indicando PD, oro y el personaje de SU
* cuenta que recibirá el oro por correo cuando se canjee.
* - El comprador canjea el código (COMPRA) indicando de qué personaje de SU
* cuenta se cobra el oro.
*
* Al canjear (decisión de diseño «al canjear», no escrow):
* 1. Se descuenta el oro del personaje del comprador (debe estar desconectado).
* 2. Los PD pasan de la cuenta del creador a la del comprador.
* 3. El oro se envía por correo (SOAP) al personaje del creador.
* Cada código dura 1 hora. Tras un canje correcto, la cuenta del comprador queda
* «bloqueada» 5 s (no puede canjear otro código) como medida de seguridad.
*/
const CODE_TTL_MS = 60 * 60 * 1000 // 1 hora
const REDEEM_LOCK_SECONDS = 5
const GOLD_TO_COPPER = 10000
const MAX_AMOUNT = 99999 // el input admite máx. 5 dígitos
/** Nombres de personaje válidos (evita inyección en comandos SOAP). */
const NAME_RE = /^[A-Za-z]{1,12}$/
export interface Result {
success: boolean
error?: string
}
export interface TradeCodeInfo {
code: string
points: number
gold: number
expiresAt: string
}
function validAmount(n: number): boolean {
return Number.isInteger(n) && n >= 1 && n <= MAX_AMOUNT
}
/** Genera un código único legible de <=25 caracteres (p.ej. TP-3F9K2A7QX8B1). */
function generateCode(): string {
return 'TP-' + crypto.randomBytes(9).toString('hex').toUpperCase() // "TP-" + 18 hex = 21
}
/** Marca como expirados los códigos activos cuyo plazo venció (limpieza perezosa). */
async function expireOldCodes(): Promise<void> {
try {
await db(DB.default).query(
"UPDATE tradecode SET status = 'expired' WHERE status = 'active' AND expires_at <= NOW()",
)
} catch {
/* ignore */
}
}
/** True si el personaje pertenece a la cuenta. Devuelve el saldo (copper) si sí. */
async function characterMoney(accountId: number, name: string): Promise<{ money: number; online: boolean } | null> {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'SELECT money, online FROM characters WHERE name = ? AND account = ? LIMIT 1',
[name, accountId],
)
if (!rows[0]) return null
return { money: Number(rows[0].money), online: rows[0].online === 1 }
}
/* -------------------------------- VENTA ----------------------------------- */
export async function createTradeCode(params: {
creatorAccount: number
creatorEmail: string
creatorCharacter: string
points: number
gold: number
password: string
token: string
turnstileOk: boolean
}): Promise<{ success: boolean; error?: string; code?: TradeCodeInfo }> {
const { creatorAccount, creatorEmail, creatorCharacter, points, gold, password, token, turnstileOk } = params
if (!creatorCharacter || !password || !token) return { success: false, error: 'missingFields' }
if (!validAmount(points) || !validAmount(gold)) return { success: false, error: 'invalidAmount' }
if (!NAME_RE.test(creatorCharacter)) return { success: false, error: 'characterNotFound' }
if (!turnstileOk) return { success: false, error: 'captcha' }
// El personaje que recibirá el oro debe ser de la cuenta del creador.
const owned = await characterMoney(creatorAccount, creatorCharacter)
if (!owned) return { success: false, error: 'characterNotOwned' }
if (!(await authenticate(creatorEmail, password))) return { success: false, error: 'wrongPassword' }
if (!(await checkSecurityToken(creatorAccount, token))) return { success: false, error: 'invalidToken' }
await expireOldCodes()
// No hay escrow: comprobamos que el saldo cubre TODOS los códigos activos + este,
// para no poder crear códigos que no se puedan respaldar.
try {
const [bal] = await db(DB.default).query<RowDataPacket[]>(
'SELECT dp FROM api_points WHERE accountID = ?',
[creatorAccount],
)
const balance = bal[0] ? Number(bal[0].dp) : 0
const [act] = await db(DB.default).query<RowDataPacket[]>(
"SELECT COALESCE(SUM(points), 0) AS reserved FROM tradecode WHERE creator_account = ? AND status = 'active' AND expires_at > NOW()",
[creatorAccount],
)
const reserved = Number(act[0]?.reserved ?? 0)
if (reserved + points > balance) return { success: false, error: 'insufficientPoints' }
} catch {
return { success: false, error: 'genericError' }
}
const code = generateCode()
const expiresAt = new Date(Date.now() + CODE_TTL_MS)
try {
await db(DB.default).query(
`INSERT INTO tradecode
(code, creator_account, creator_character, points, gold, status, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, 'active', NOW(), ?)`,
[code, creatorAccount, creatorCharacter, points, gold, expiresAt],
)
} catch {
return { success: false, error: 'genericError' }
}
return { success: true, code: { code, points, gold, expiresAt: expiresAt.toISOString() } }
}
/* ------------------------------- CHEQUEAR --------------------------------- */
export async function checkTradeCode(code: string): Promise<{ success: boolean; error?: string; points?: number; gold?: number; expiresAt?: string }> {
const trimmed = code.trim()
if (!trimmed) return { success: false, error: 'missingFields' }
await expireOldCodes()
try {
const [rows] = await db(DB.default).query<RowDataPacket[]>(
"SELECT points, gold, status, expires_at FROM tradecode WHERE code = ? LIMIT 1",
[trimmed],
)
const row = rows[0]
if (!row) return { success: false, error: 'codeNotFound' }
if (row.status !== 'active' || new Date(row.expires_at).getTime() <= Date.now()) {
return { success: false, error: 'codeUnavailable' }
}
return { success: true, points: Number(row.points), gold: Number(row.gold), expiresAt: new Date(row.expires_at).toISOString() }
} catch {
return { success: false, error: 'genericError' }
}
}
/* -------------------------------- CANJEAR --------------------------------- */
export async function redeemTradeCode(params: {
buyerAccount: number
buyerEmail: string
buyerCharacter: string
code: string
password: string
token: string
turnstileOk: boolean
}): Promise<{ success: boolean; error?: string; points?: number; gold?: number }> {
const { buyerAccount, buyerEmail, buyerCharacter, code, password, token, turnstileOk } = params
const trimmedCode = code.trim()
if (!trimmedCode || !buyerCharacter || !password || !token) return { success: false, error: 'missingFields' }
if (!NAME_RE.test(buyerCharacter)) return { success: false, error: 'characterNotOwned' }
if (!turnstileOk) return { success: false, error: 'captcha' }
if (!(await authenticate(buyerEmail, password))) return { success: false, error: 'wrongPassword' }
if (!(await checkSecurityToken(buyerAccount, token))) return { success: false, error: 'invalidToken' }
// Bloqueo de 5 s: la cuenta no puede canjear si acaba de canjear otro código.
try {
const [recent] = await db(DB.default).query<RowDataPacket[]>(
`SELECT id FROM tradecode
WHERE redeemer_account = ? AND redeemed_at > (NOW() - INTERVAL ? SECOND) LIMIT 1`,
[buyerAccount, REDEEM_LOCK_SECONDS],
)
if (recent[0]) return { success: false, error: 'locked' }
} catch {
/* ignore */
}
await expireOldCodes()
// Carga y valida el código.
let creatorAccount = 0
let creatorCharacter = ''
let points = 0
let gold = 0
try {
const [rows] = await db(DB.default).query<RowDataPacket[]>(
"SELECT creator_account, creator_character, points, gold, status, expires_at FROM tradecode WHERE code = ? LIMIT 1",
[trimmedCode],
)
const row = rows[0]
if (!row) return { success: false, error: 'codeNotFound' }
if (row.status !== 'active' || new Date(row.expires_at).getTime() <= Date.now()) {
return { success: false, error: 'codeUnavailable' }
}
creatorAccount = Number(row.creator_account)
creatorCharacter = String(row.creator_character)
points = Number(row.points)
gold = Number(row.gold)
} catch {
return { success: false, error: 'genericError' }
}
if (creatorAccount === buyerAccount) return { success: false, error: 'cannotRedeemOwn' }
if (!NAME_RE.test(creatorCharacter)) return { success: false, error: 'genericError' }
// El personaje del comprador debe ser suyo, estar OFFLINE y tener oro suficiente.
const buyerChar = await characterMoney(buyerAccount, buyerCharacter)
if (!buyerChar) return { success: false, error: 'characterNotOwned' }
if (buyerChar.online) return { success: false, error: 'characterOnline' }
const costCopper = gold * GOLD_TO_COPPER
if (buyerChar.money < costCopper) return { success: false, error: 'insufficientGold' }
// 1) Reclama el código de forma atómica (evita doble canje concurrente).
let claimed = false
try {
const [res] = await db(DB.default).query<ResultSetHeader>(
`UPDATE tradecode
SET status = 'redeemed', redeemer_account = ?, redeemer_character = ?, redeemed_at = NOW()
WHERE code = ? AND status = 'active' AND expires_at > NOW()`,
[buyerAccount, buyerCharacter, trimmedCode],
)
claimed = res.affectedRows === 1
} catch {
return { success: false, error: 'genericError' }
}
if (!claimed) return { success: false, error: 'codeUnavailable' }
const revertClaim = async () => {
try {
await db(DB.default).query(
"UPDATE tradecode SET status = 'active', redeemer_account = NULL, redeemer_character = NULL, redeemed_at = NULL WHERE code = ?",
[trimmedCode],
)
} catch {
/* ignore */
}
}
// 2) Cobra el oro al personaje del comprador (condicional: offline y saldo).
let goldTaken = false
try {
const [res] = await db(DB.characters).query<ResultSetHeader>(
'UPDATE characters SET money = money - ? WHERE name = ? AND account = ? AND online = 0 AND money >= ?',
[costCopper, buyerCharacter, buyerAccount, costCopper],
)
goldTaken = res.affectedRows === 1
} catch {
goldTaken = false
}
if (!goldTaken) {
await revertClaim()
return { success: false, error: 'insufficientGold' }
}
const refundGold = async () => {
try {
await db(DB.characters).query('UPDATE characters SET money = money + ? WHERE name = ? AND account = ?', [
costCopper,
buyerCharacter,
buyerAccount,
])
} catch {
/* ignore */
}
}
// 3) Mueve los PD del creador al comprador (transacción con bloqueo de fila).
const pdMove = await transferDPoints(creatorAccount, buyerAccount, points)
if (!pdMove.success) {
await refundGold()
await revertClaim()
return { success: false, error: pdMove.error === 'insufficientFunds' ? 'creatorInsufficient' : 'genericError' }
}
// 4) Envía el oro por correo al personaje del creador (SOAP). Último paso.
const subject = 'Comercio de PD'
const body = `Has recibido ${gold} de oro por el canje de tu codigo ${trimmedCode}.`
const soap = await executeSoapCommand(`.send money ${creatorCharacter} '${subject}' '${body}' ${costCopper}`)
if (soap === null) {
// Compensa: revierte PD y oro, y reabre el código.
await transferDPoints(buyerAccount, creatorAccount, points)
await refundGold()
await revertClaim()
return { success: false, error: 'deliveryFailed' }
}
return { success: true, points, gold }
}