prefix}mowl_carts` * literálként áll (nem ezen a konstanson át) — lásd EventQueue::TABLE_SUFFIX * docblockja: a Plugin Check UnescapedDBParameter sniffje csak a * $wpdb-tulajdonságos interpolációt fogadja el biztonságosnak, egy * metódusból kapott $table változót nem. Aki átnevezi a táblát, a * literálokat is cserélje. */ public const TABLE_SUFFIX = 'mowl_carts'; /** * A kosár-tábla saját verziója, függetlenül az EventQueue::DB_VERSION-től. * * '2': a session_key UNIQUE KEY lett (volt: sima KEY) — az upsert() innentől * egyetlen atomi `INSERT ... ON DUPLICATE KEY UPDATE`, ami versenyhelyzet * (két egyidejű kosár-AJAX ugyanarra a session_key-re) nélkül csak így * garantálja, hogy egy session_key-hez legfeljebb egy sor tartozzon. Az * 1 -> 2 lépés install()-ban explicit ALTER-rel migrál (lásd * migrate_session_key_to_unique()); a tábla adatai (kosarak, tokenek) * SOHA nem vesznek el egy verzióváltás miatt. */ public const DB_VERSION = '2'; public const STATUS_OPEN = 'open'; public const STATUS_ABANDONED = 'abandoned'; public const STATUS_RECOVERED = 'recovered'; public static function table(): string { global $wpdb; return $wpdb->prefix . self::TABLE_SUFFIX; } public static function install(): void { global $wpdb; $current_version = get_option( 'mowl_carts_db_version' ); if ( self::DB_VERSION === $current_version ) { return; } if ( '1' === $current_version ) { self::migrate_session_key_to_unique(); } require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $charset = $wpdb->get_charset_collate(); dbDelta( "CREATE TABLE {$wpdb->prefix}mowl_carts ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, token char(32) NOT NULL, session_key varchar(64) NOT NULL, customer_id bigint(20) unsigned NOT NULL DEFAULT 0, email varchar(191) NOT NULL DEFAULT '', items longtext NOT NULL, items_count int unsigned NOT NULL DEFAULT 0, total decimal(12,2) NOT NULL DEFAULT 0, currency char(3) NOT NULL DEFAULT '', coupon_codes varchar(191) NOT NULL DEFAULT '', status varchar(16) NOT NULL DEFAULT 'open', created_at datetime NOT NULL, updated_at datetime NOT NULL, abandoned_at datetime NULL, recovered_at datetime NULL, notified_at datetime NULL, PRIMARY KEY (id), UNIQUE KEY token (token), UNIQUE KEY session_key (session_key), KEY email (email), KEY status_updated (status, updated_at) ) {$charset};" ); // Autoload: install() minden kérésen lefut (plugins_loaded), a // verzió-ellenőrzése get_option()-t hív — ha az option nem autoload, // ez perzisztens object cache nélkül egy külön SELECT-et jelent // minden egyes oldalletöltésen. update_option( 'mowl_carts_db_version', self::DB_VERSION ); } /** * 1 -> 2 explicit migráció: a session_key sima KEY-ből UNIQUE KEY lesz. * A dbDelta ezt helyben NEM tudja megtenni — egy meglévő, azonos nevű, * nem-unique KEY-t nem cserél UNIQUE KEY-re (csendben "Duplicate key * name" hibát ad, a kulcs marad nem-unique) —, ezért az ALTER-t itt, * explicit adjuk ki; az install() ez után hívott dbDelta()-ja innentől * a normál (egyéb, jövőbeli sémakülönbségeket kezelő) útként fut. A * táblát emiatt SOHA nem dobjuk el itt: éles boltban a kosarak (és a * bennük lévő, vásárlói helyreállító linkekben szereplő tokenek) egy * verzióváltás miatt nem veszhetnek el — a DROP TABLE kizárólag a * drop() (uninstall) dolga. */ private static function migrate_session_key_to_unique(): void { global $wpdb; // Az ALTER ... ADD UNIQUE KEY duplikált session_key mellett elszállna // (Duplicate entry) — előbb minden ismétlődő session_key-ből csak a // legutóbb módosított sort tartjuk meg (holtverseny esetén a nagyobb // id-t), a többit töröljük. // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- 1->2 migráció, a táblanév saját, nincs felhasználói bemenet $duplicate_keys = $wpdb->get_col( "SELECT session_key FROM {$wpdb->prefix}mowl_carts GROUP BY session_key HAVING COUNT(*) > 1" ); foreach ( $duplicate_keys as $session_key ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- 1->2 migráció, a táblanév saját, az érték prepare-relve $keep_id = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}mowl_carts WHERE session_key = %s ORDER BY updated_at DESC, id DESC LIMIT 1", $session_key ) ); $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- 1->2 migráció, saját tábla $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- a táblanév saját, az értékek prepare-relve "DELETE FROM {$wpdb->prefix}mowl_carts WHERE session_key = %s AND id <> %d", $session_key, (int) $keep_id ) ); } // A duplikátumok törlése után az ALTER már biztonságosan lecserélheti // a nem-unique session_key KEY-t UNIQUE KEY-re. $wpdb->query( "ALTER TABLE {$wpdb->prefix}mowl_carts DROP INDEX session_key, ADD UNIQUE KEY session_key (session_key)" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL -- 1->2 séma-migráció, az azonosító nem paraméterezhető } public static function drop(): void { global $wpdb; $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}mowl_carts" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL -- uninstall, azonosító nem paraméterezhető delete_option( 'mowl_carts_db_version' ); } /** * Kosár beszúrása/frissítése session_key alapján, egyetlen atomi * `INSERT ... ON DUPLICATE KEY UPDATE` utasítással — a session_key UNIQUE * KEY (lásd install()) nélkül két egyidejű kosár-AJAX (mindkettő * find_by_session()-nal "nincs sor"-t lát, mindkettő insert-el) két sort * hozna létre ugyanarra a kosárra; ez az atomi forma versenyhelyzet * nélkül garantálja a legfeljebb egy sort. `$data` kulcsai: customer_id, * email, items, items_count, total, currency, coupon_codes. * * Új sornál a token véletlenszerűen generálódik és a status "open"; * meglévő sornál (session_key ütközés) csak a felsorolt mezők frissülnek — * a token, a created_at és a notified_at nincs az UPDATE-részben, tehát * érintetlen marad. * * C2: az `email` és a `customer_id` az ütközési ágon NEM destruktív — üres * e-mail (illetve 0 customer_id) nem írja felül a már ismertet. A * kosár-hookok minden kosár-változásnál tükröznek, és vendégnél üres * e-mailt adnak (a CartHooks::mirror() csak bejelentkezett vevő címét * ismeri) — enélkül a pénztárnál begépelt (EmailCapture-rel elkapott) cím * a következő "kosárba tesz"-nél elveszne, és a vendég sosem kapna * emlékeztetőt. Aki a címet el akarja engedni, ne üres stringet * upsert-áljon, hanem törölje a sort (forget()). * * @param array $data Lásd fent. */ public static function upsert( string $session_key, array $data ): void { global $wpdb; $now = current_time( 'mysql', true ); $token = bin2hex( random_bytes( 16 ) ); $fields = array( 'customer_id' => (int) ( $data['customer_id'] ?? 0 ), 'email' => (string) ( $data['email'] ?? '' ), // Érvénytelen UTF-8 (pl. egy rosszul kódolt terméknév) U+FFFD-re // cserélve, hogy az encode ne adjon false-t és a sor ne vesszen el. 'items' => wp_json_encode( $data['items'] ?? array(), JSON_INVALID_UTF8_SUBSTITUTE ), 'items_count' => (int) ( $data['items_count'] ?? 0 ), 'total' => (float) ( $data['total'] ?? 0.0 ), 'currency' => (string) ( $data['currency'] ?? '' ), 'coupon_codes' => (string) ( $data['coupon_codes'] ?? '' ), ); $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- saját tábla $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- a táblanév saját, nem paraméterezhető; az összes érték lent prepare-relve "INSERT INTO {$wpdb->prefix}mowl_carts (token, session_key, customer_id, email, items, items_count, total, currency, coupon_codes, status, created_at, updated_at) VALUES (%s, %s, %d, %s, %s, %d, %f, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE customer_id = IF(VALUES(customer_id) = 0, customer_id, VALUES(customer_id)), email = IF(VALUES(email) = '', email, VALUES(email)), items = VALUES(items), items_count = VALUES(items_count), total = VALUES(total), currency = VALUES(currency), coupon_codes = VALUES(coupon_codes), status = VALUES(status), updated_at = VALUES(updated_at)", $token, $session_key, $fields['customer_id'], $fields['email'], $fields['items'], $fields['items_count'], $fields['total'], $fields['currency'], $fields['coupon_codes'], self::STATUS_OPEN, $now, $now ) ); } /** * A pénztárban megismert e-mail (és a bejelentkezett vevő azonosítója) a * meglévő sorra, a tételek érintése nélkül. Ugyanazt írja, amit az * upsert() ütközési ága írna e két mezőre: a sor `open`-re és friss * `updated_at`-ra áll, a 0/null customer_id nem írja felül az ismertet. * A hívó nem üres, érvényes címet ad (EmailCapture::capture()). */ public static function set_email( string $session_key, string $email, ?int $customer_id ): void { global $wpdb; $data = array( 'email' => $email, 'status' => self::STATUS_OPEN, 'updated_at' => current_time( 'mysql', true ), ); if ( $customer_id > 0 ) { $data['customer_id'] = $customer_id; } $wpdb->update( self::table(), $data, array( 'session_key' => $session_key ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- saját tábla } /** * Egy sor tételei. A `mowl_carts.items` oszlop JSON-stringként jön a valódi * sorokból (nyers $wpdb-eredmény); egy kézzel összerakott teszt-sor * kényelmi okból már tömböt is adhat. * * @return array> */ public static function items( object $row ): array { $items = $row->items ?? null; if ( is_array( $items ) ) { return $items; } $decoded = json_decode( (string) $items, true ); return is_array( $decoded ) ? $decoded : array(); } /** Üres kosár: a sor törlése. */ public static function forget( string $session_key ): void { global $wpdb; $wpdb->delete( self::table(), array( 'session_key' => $session_key ), array( '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- saját tábla } /** * I4: a vendég-munkamenet sorának átkulcsolása a bejelentkezett vevő * munkamenet-kulcsára. A WooCommerce belépéskor a vendég-munkamenetet a * felhasználó munkamenetébe költözteti, és a régi kulcsot eldobja (lásd * WC_Session_Handler::migrate_guest_session_to_user_session()) — enélkül a * vendégként összerakott kosár sora "árván" maradna a régi kulcson (a * belépett vevő új sort kapna), és a pénztárnál begépelt e-mail sem * tartozna többé az élő kosárhoz. * * A session_key UNIQUE (lásd DB_VERSION), tehát ha a cél-kulcson már van * sor, a kettőből egy marad: az, amelyiknek van e-mailje (az kaphat * emlékeztetőt), egyébként a frissebben módosult; a másik sor törlődik. */ public static function rekey_session( string $old_key, string $new_key ): void { global $wpdb; if ( '' === $old_key || '' === $new_key || $old_key === $new_key ) { return; } $guest = self::find_by_session( $old_key ); if ( null === $guest ) { return; } $existing = self::find_by_session( $new_key ); if ( null !== $existing ) { if ( ! self::wins( $guest, $existing ) ) { self::forget( $old_key ); return; } $wpdb->delete( self::table(), array( 'id' => (int) $existing->id ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- saját tábla } $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- saját tábla self::table(), array( 'session_key' => $new_key ), array( 'id' => (int) $guest->id ), array( '%s' ), array( '%d' ) ); } /** * A visszaállító linkkel (CartRestore) újratöltött sor átvétele a mostani * munkamenetbe. Más eszközön / lejárt süti után a sor a régi * munkamenet-kulcson ül; ha ott maradna, a visszatöltés utáni tükrözés * (CartHooks::mirror(), EmailCapture) a mostani kulcson egy MÁSODIK, * `open` sort nyitna — a rendelésnél ez a sor `open` maradna, és a * söprés egy épp megvett kosárról küldene `cart_abandoned`-ot. * * A session_key UNIQUE (lásd DB_VERSION): a $session_key-en már ülő másik * sor törlődik (a kosarat a visszatöltés úgyis kiürítette), majd a $id sor * kulcsa $session_key lesz — a státusz és az updated_at érintetlen, a sor * `abandoned` marad, a linkje él tovább. Szándékosan nem rekey_session(): * annak wins() ütközési szabálya (e-mail, frissebb updated_at) a * visszaállítás alatt lévő sort is eldobhatná a mostani munkamenet * sorával szemben. */ public static function adopt_session( int $id, string $session_key ): void { global $wpdb; if ( $id <= 0 || '' === $session_key ) { return; } $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- saját tábla $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- a táblanév saját, az értékek prepare-relve "DELETE FROM {$wpdb->prefix}mowl_carts WHERE session_key = %s AND id <> %d", $session_key, $id ) ); $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- saját tábla self::table(), array( 'session_key' => $session_key ), array( 'id' => $id ), array( '%s' ), array( '%d' ) ); } /** Ütközéskor a $candidate maradjon-e a $rival helyett? Lásd rekey_session(). */ private static function wins( object $candidate, object $rival ): bool { $candidate_email = '' !== (string) $candidate->email; $rival_email = '' !== (string) $rival->email; if ( $candidate_email !== $rival_email ) { return $candidate_email; } return (string) $candidate->updated_at > (string) $rival->updated_at; } public static function find_by_session( string $session_key ): ?object { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- a táblanév saját, az érték prepare-relve $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}mowl_carts WHERE session_key = %s LIMIT 1", $session_key ) ); return $row instanceof \stdClass ? $row : null; } public static function find_by_id( int $id ): ?object { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- a táblanév saját, az érték prepare-relve $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}mowl_carts WHERE id = %d LIMIT 1", $id ) ); return $row instanceof \stdClass ? $row : null; } public static function find_by_token( string $token ): ?object { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- a táblanév saját, az érték prepare-relve $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}mowl_carts WHERE token = %s LIMIT 1", $token ) ); return $row instanceof \stdClass ? $row : null; } public static function find_open_by_email( string $email ): ?object { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- a táblanév saját, az értékek prepare-relve $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}mowl_carts WHERE email = %s AND status = %s ORDER BY updated_at DESC LIMIT 1", $email, self::STATUS_OPEN ) ); return $row instanceof \stdClass ? $row : null; } /** * A leküldésre esedékes, nyitott kosarak: a küszöbnél régebben módosultak, * van email-jük, és vagy még nem kaptak emlékeztetőt, vagy a renotify-ablaknál * régebben kaptak. * * I6: a kornak alsó határa is van ($max_age_days, a hívó * CartSweeper::MAX_AGE_DAYS-e) — egy leállás (kikapcsolt sync, halott * Action Scheduler, karbantartás) után különben a felgyűlt hátralék is * kimenne, és a vásárló egy hetekkel korábbi kosárról kapna "ne felejtsd * el" levelet. Az ennél régebben módosult sorokat a napi prune viszi el. * A sorrend emiatt a legfrissebbel kezd (nem a legrégebbivel): a limitet * elérő kötegben a még releváns kosarak mennek ki előbb. * * @return array */ public static function due( int $threshold_minutes, int $renotify_hours = 24, int $limit = 50, int $max_age_days = 7 ): array { global $wpdb; $before_updated = gmdate( 'Y-m-d H:i:s', time() - $threshold_minutes * MINUTE_IN_SECONDS ); $after_updated = gmdate( 'Y-m-d H:i:s', time() - $max_age_days * DAY_IN_SECONDS ); $before_notified = gmdate( 'Y-m-d H:i:s', time() - $renotify_hours * HOUR_IN_SECONDS ); return (array) $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- saját tábla $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- a táblanév saját, nem paraméterezhető; az email <> '' literál, nem felhasználói bemenet "SELECT * FROM {$wpdb->prefix}mowl_carts WHERE status = %s AND email <> '' AND updated_at <= %s AND updated_at >= %s AND (notified_at IS NULL OR notified_at <= %s) ORDER BY updated_at DESC LIMIT %d", self::STATUS_OPEN, $before_updated, $after_updated, $before_notified, $limit ) ); } /** * M13: csak `open` sorból lehet `abandoned`. A söprés a due() sorain megy * végig, de a lekérdezés és az írás között a vásárló még rendelhet (a sor * `recovered`-re áll) vagy visszatérhet a kosárhoz — a státusz-feltétel * nélkül egy már lezárt kosarat írnánk vissza elhagyottra. */ public static function mark_abandoned( int $id ): void { global $wpdb; $now = current_time( 'mysql', true ); $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- saját tábla self::table(), array( 'status' => self::STATUS_ABANDONED, 'abandoned_at' => $now, 'notified_at' => $now, ), array( 'id' => $id, 'status' => self::STATUS_OPEN, ) ); } public static function mark_recovered( int $id ): void { global $wpdb; $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- saját tábla self::table(), array( 'status' => self::STATUS_RECOVERED, 'recovered_at' => current_time( 'mysql', true ), ), array( 'id' => $id ) ); } /** @return array{open: int, abandoned: int, recovered: int} */ public static function counts(): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- a táblanév saját, nincs felhasználói bemenet $row = $wpdb->get_row( "SELECT SUM(status = 'open') AS open, SUM(status = 'abandoned') AS abandoned, SUM(status = 'recovered') AS recovered FROM {$wpdb->prefix}mowl_carts", ARRAY_A ); return array( 'open' => (int) ( $row['open'] ?? 0 ), 'abandoned' => (int) ( $row['abandoned'] ?? 0 ), 'recovered' => (int) ( $row['recovered'] ?? 0 ), ); } /** * A visszatért (recovered) sorok, továbbá a megadott napnál régebben * módosult elhagyott (abandoned) és nyitott (open) sorok törlése. * * Az `open` sorokra szándékosan ugyanaz az ablak érvényes: a tükrözött * kosarak többsége sosem lesz `abandoned` (e-mail nélkül a due() sosem * adja vissza őket), tehát nélküle a tábla korlátlanul nőne. * * Az ablak az `updated_at`-en mér, és a visszaallító link lejárata * (CartRestore::EXPIRY_DAYS, lásd CartRestore::is_expired()) ugyanezen az * oszlopon, ugyanennyi nappal: a token így pontosan addig él, amíg a sora * — egy még érvényes link sosem fut már kitörölt sorba. Aki az egyiket * átállítja, állítsa át a másikat is (a Dispatcher::PRUNE_DAYS adja a napokat). */ public static function purge( int $days = 30 ): int { global $wpdb; $before = gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- a táblanév saját, az értékek prepare-relve return (int) $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}mowl_carts WHERE status = %s OR (status IN (%s, %s) AND updated_at < %s)", self::STATUS_RECOVERED, self::STATUS_ABANDONED, self::STATUS_OPEN, $before ) ); } /** * Minden kosár-sor törlése — szétkapcsoláskor és másik integrációra * (workspace-re) kapcsoláskor, az EventQueue::purge_events() mintájára: a * tükrözött kosarak (e-mail címekkel, tokenekkel) nem mehetnek át a * következő workspace-be, és a szétkapcsolt bolt sem őrizgeti őket tovább. */ public static function purge_all(): int { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- a táblanév saját, nincs felhasználói bemenet return (int) $wpdb->query( "DELETE FROM {$wpdb->prefix}mowl_carts" ); } /** * A cím kosár-sorai — a személyesadat-exportálónak. A cím-összehasonlítás * a tábla kisbetű-érzéketlen kollációján történik. * * @return array */ public static function rows_for_email( string $email ): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- a táblanév saját, az érték prepare-relve return (array) $wpdb->get_results( $wpdb->prepare( "SELECT id, status, items, items_count, total, currency, created_at, updated_at FROM {$wpdb->prefix}mowl_carts WHERE email = %s ORDER BY id ASC", $email ) ); } /** A cím kosár-sorainak törlése — a személyesadat-törlőnek. */ public static function forget_email( string $email ): int { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- a táblanév saját, az érték prepare-relve return (int) $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}mowl_carts WHERE email = %s", $email ) ); } }