cccc70338d
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>
298 lines
12 KiB
TypeScript
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 }
|
|
}
|