web-next: herramientas de PD (transferir, comercio, códigos de promoción)
- /transfer-d-points: transferir PD a la cuenta dueña de un personaje (requiere token de seguridad; movimiento atómico con FOR UPDATE). - /trade-points: comercio PD<->oro mediante códigos de 1 solo uso (contraseña + token + Turnstile; oro al creador por SOAP; expira 1h; lock de 5s tras canjear; rollback total si falla la entrega). - /promo-code: canje de códigos por PD/PV (sensibles a mayúsculas/minúsculas, usos limitados, 1 canje por cuenta). - /admin/promo: panel para crear / editar / activar / desactivar / borrar códigos de promoción. - Tablas nuevas (sql/): home_tradecode, home_promocode, home_promoredemption. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import type { RowDataPacket, ResultSetHeader } from 'mysql2'
|
||||
import { db, DB } from './db'
|
||||
|
||||
export interface PromoCode {
|
||||
id: number
|
||||
code: string
|
||||
pd: number
|
||||
pv: number
|
||||
max_uses: number
|
||||
uses: number
|
||||
active: number
|
||||
expires_at: string | null
|
||||
redemptions: number
|
||||
}
|
||||
|
||||
/** Normaliza un valor datetime-local ("YYYY-MM-DDTHH:MM") a DATETIME de MySQL, o null. */
|
||||
function toMysqlDate(v: string): string | null {
|
||||
const s = v.trim()
|
||||
if (!s) return null
|
||||
return s.replace('T', ' ').slice(0, 19)
|
||||
}
|
||||
|
||||
export async function listPromoCodes(): Promise<PromoCode[]> {
|
||||
const [rows] = await db(DB.default).query<RowDataPacket[]>(
|
||||
`SELECT p.id, p.code, p.pd, p.pv, p.max_uses, p.uses, p.active, p.expires_at,
|
||||
(SELECT COUNT(*) FROM home_promoredemption r WHERE r.promo_id = p.id) AS redemptions
|
||||
FROM home_promocode p ORDER BY p.id DESC`,
|
||||
)
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
code: r.code,
|
||||
pd: r.pd,
|
||||
pv: r.pv,
|
||||
max_uses: r.max_uses,
|
||||
uses: r.uses,
|
||||
active: r.active,
|
||||
expires_at: r.expires_at ? new Date(r.expires_at).toISOString() : null,
|
||||
redemptions: Number(r.redemptions),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function createPromoCode(
|
||||
code: string,
|
||||
pd: number,
|
||||
pv: number,
|
||||
maxUses: number,
|
||||
expiresAt: string,
|
||||
): Promise<{ id?: number; error?: string }> {
|
||||
try {
|
||||
const [res] = await db(DB.default).query<ResultSetHeader>(
|
||||
'INSERT INTO home_promocode (code, pd, pv, max_uses, uses, active, expires_at, created_at) VALUES (?, ?, ?, ?, 0, 1, ?, NOW())',
|
||||
[code, pd, pv, maxUses, toMysqlDate(expiresAt)],
|
||||
)
|
||||
return { id: res.insertId }
|
||||
} catch (e) {
|
||||
if ((e as { code?: string }).code === 'ER_DUP_ENTRY') return { error: 'duplicate' }
|
||||
return { error: 'genericError' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePromoCode(
|
||||
id: number,
|
||||
fields: { pd: number; pv: number; maxUses: number; active: boolean },
|
||||
): Promise<void> {
|
||||
await db(DB.default).query(
|
||||
'UPDATE home_promocode SET pd = ?, pv = ?, max_uses = ?, active = ? WHERE id = ?',
|
||||
[fields.pd, fields.pv, fields.maxUses, fields.active ? 1 : 0, id],
|
||||
)
|
||||
}
|
||||
|
||||
export async function deletePromoCode(id: number): Promise<void> {
|
||||
await db(DB.default).query('DELETE FROM home_promoredemption WHERE promo_id = ?', [id])
|
||||
await db(DB.default).query('DELETE FROM home_promocode WHERE id = ?', [id])
|
||||
}
|
||||
@@ -19,6 +19,21 @@ export async function getGameCharacters(accountId: number): Promise<GameCharacte
|
||||
}
|
||||
}
|
||||
|
||||
/** Id de la cuenta dueña de un personaje por su nombre (único), o null. */
|
||||
export async function getAccountIdByCharacterName(name: string): Promise<number | null> {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return null
|
||||
try {
|
||||
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
|
||||
'SELECT account FROM characters WHERE name = ? LIMIT 1',
|
||||
[trimmed],
|
||||
)
|
||||
return rows[0] ? Number(rows[0].account) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** True si el personaje pertenece a la cuenta y está desconectado. */
|
||||
export async function characterIsOfflineAndOwned(accountId: number, name: string): Promise<boolean> {
|
||||
const chars = await getGameCharacters(accountId)
|
||||
|
||||
@@ -25,3 +25,63 @@ export async function creditDPoints(accountId: number, points: number): Promise<
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Saldo de PD (donate points) de una cuenta de juego. 0 si no tiene fila. */
|
||||
export async function getDPointsBalance(accountId: number): Promise<number> {
|
||||
if (!accountId) return 0
|
||||
try {
|
||||
const [rows] = await db(DB.default).query<RowDataPacket[]>(
|
||||
'SELECT dp FROM home_api_points WHERE accountID = ?',
|
||||
[accountId],
|
||||
)
|
||||
return rows[0] ? Number(rows[0].dp) : 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfiere `points` PD desde una cuenta de origen a una de destino de forma
|
||||
* atómica (transacción con bloqueo de fila del origen). Es irreversible.
|
||||
*/
|
||||
export async function transferDPoints(
|
||||
fromAccountId: number,
|
||||
toAccountId: number,
|
||||
points: number,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
if (!fromAccountId || !toAccountId || !Number.isInteger(points) || points <= 0) {
|
||||
return { success: false, error: 'invalidRequest' }
|
||||
}
|
||||
if (fromAccountId === toAccountId) return { success: false, error: 'sameAccount' }
|
||||
|
||||
const conn = await db(DB.default).getConnection()
|
||||
try {
|
||||
await conn.beginTransaction()
|
||||
// Bloquea la fila del origen para evitar dobles gastos concurrentes.
|
||||
const [src] = await conn.query<RowDataPacket[]>(
|
||||
'SELECT dp FROM home_api_points WHERE accountID = ? FOR UPDATE',
|
||||
[fromAccountId],
|
||||
)
|
||||
const balance = src[0] ? Number(src[0].dp) : 0
|
||||
if (balance < points) {
|
||||
await conn.rollback()
|
||||
return { success: false, error: 'insufficientFunds' }
|
||||
}
|
||||
await conn.query('UPDATE home_api_points SET dp = dp - ? WHERE accountID = ?', [points, fromAccountId])
|
||||
await conn.query(
|
||||
'INSERT INTO home_api_points (accountID, vp, dp) VALUES (?, 0, ?) ON DUPLICATE KEY UPDATE dp = dp + ?',
|
||||
[toAccountId, points, points],
|
||||
)
|
||||
await conn.commit()
|
||||
return { success: true }
|
||||
} catch {
|
||||
try {
|
||||
await conn.rollback()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return { success: false, error: 'genericError' }
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { RowDataPacket, ResultSetHeader } from 'mysql2'
|
||||
import { db, DB } from './db'
|
||||
|
||||
/**
|
||||
* Códigos de promoción: se canjean por PD y/o PV. Cada código tiene usos totales
|
||||
* limitados (`max_uses`) y solo puede canjearse una vez por cuenta. Los códigos
|
||||
* son sensibles a mayúsculas/minúsculas (columna con collation utf8mb4_bin).
|
||||
*/
|
||||
|
||||
export interface RedeemResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
pd?: number
|
||||
pv?: number
|
||||
}
|
||||
|
||||
export async function redeemPromoCode(accountId: number, rawCode: string): Promise<RedeemResult> {
|
||||
const code = rawCode.trim()
|
||||
if (!accountId) return { success: false, error: 'notAuthenticated' }
|
||||
if (!code) return { success: false, error: 'missingCode' }
|
||||
if (code.length > 64) return { success: false, error: 'notFound' }
|
||||
|
||||
const conn = await db(DB.default).getConnection()
|
||||
try {
|
||||
await conn.beginTransaction()
|
||||
|
||||
// Bloquea la fila del código (comparación binaria => sensible a may/min).
|
||||
const [rows] = await conn.query<RowDataPacket[]>(
|
||||
'SELECT id, pd, pv, max_uses, uses, active, expires_at FROM home_promocode WHERE code = ? COLLATE utf8mb4_bin LIMIT 1 FOR UPDATE',
|
||||
[code],
|
||||
)
|
||||
const promo = rows[0]
|
||||
if (!promo || promo.active !== 1) {
|
||||
await conn.rollback()
|
||||
return { success: false, error: 'notFound' }
|
||||
}
|
||||
if (promo.expires_at && new Date(promo.expires_at).getTime() <= Date.now()) {
|
||||
await conn.rollback()
|
||||
return { success: false, error: 'expired' }
|
||||
}
|
||||
if (Number(promo.uses) >= Number(promo.max_uses)) {
|
||||
await conn.rollback()
|
||||
return { success: false, error: 'exhausted' }
|
||||
}
|
||||
|
||||
// Registra el canje (la clave única promo+cuenta evita el doble uso).
|
||||
try {
|
||||
await conn.query('INSERT INTO home_promoredemption (promo_id, account_id, redeemed_at) VALUES (?, ?, NOW())', [
|
||||
promo.id,
|
||||
accountId,
|
||||
])
|
||||
} catch (e) {
|
||||
await conn.rollback()
|
||||
if ((e as { code?: string }).code === 'ER_DUP_ENTRY') return { success: false, error: 'alreadyUsed' }
|
||||
return { success: false, error: 'genericError' }
|
||||
}
|
||||
|
||||
// Consume un uso (condicional, por si hubo carrera con otro canje).
|
||||
const [upd] = await conn.query<ResultSetHeader>(
|
||||
'UPDATE home_promocode SET uses = uses + 1 WHERE id = ? AND uses < max_uses',
|
||||
[promo.id],
|
||||
)
|
||||
if (upd.affectedRows !== 1) {
|
||||
await conn.rollback()
|
||||
return { success: false, error: 'exhausted' }
|
||||
}
|
||||
|
||||
// Acredita PD/PV a la cuenta (upsert).
|
||||
const pd = Number(promo.pd)
|
||||
const pv = Number(promo.pv)
|
||||
await conn.query(
|
||||
'INSERT INTO home_api_points (accountID, vp, dp) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE vp = vp + VALUES(vp), dp = dp + VALUES(dp)',
|
||||
[accountId, pv, pd],
|
||||
)
|
||||
|
||||
await conn.commit()
|
||||
return { success: true, pd, pv }
|
||||
} catch {
|
||||
try {
|
||||
await conn.rollback()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return { success: false, error: 'genericError' }
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
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 home_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 home_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 home_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 home_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 home_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 home_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 home_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 home_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 home_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 }
|
||||
}
|
||||
Reference in New Issue
Block a user