diff --git a/backend_api_python/app/services/billing_service.py b/backend_api_python/app/services/billing_service.py index 56e2b16..9eb99b0 100644 --- a/backend_api_python/app/services/billing_service.py +++ b/backend_api_python/app/services/billing_service.py @@ -283,7 +283,7 @@ class BillingService: updated_at = NOW() WHERE id = ? """, - (vip_expires_at, vip_plan, 1 if vip_is_lifetime else 0, user_id), + (vip_expires_at, vip_plan, bool(vip_is_lifetime), user_id), ) # Credits grants diff --git a/backend_api_python/app/services/oauth_service.py b/backend_api_python/app/services/oauth_service.py index bbb43bb..8b7b5b3 100644 --- a/backend_api_python/app/services/oauth_service.py +++ b/backend_api_python/app/services/oauth_service.py @@ -5,7 +5,7 @@ import os import secrets import requests from urllib.parse import urlencode, urlparse -from datetime import datetime +from datetime import datetime, timezone, timedelta from typing import Tuple, Optional, Dict, Any from app.utils.db import get_db_connection from app.utils.logger import get_logger @@ -26,9 +26,121 @@ def get_oauth_service(): class OAuthService: """OAuth service for Google and GitHub authentication""" - + + # Class-level cache: only create OAuth state table once per process + _state_schema_ensured: bool = False + def __init__(self): self._load_config() + + def _oauth_state_ttl_minutes(self) -> int: + try: + return max(5, min(120, int(float(os.getenv("OAUTH_STATE_TTL_MINUTES", "20") or 20)))) + except Exception: + return 20 + + def _ensure_oauth_state_schema(self, cur) -> None: + """Create qd_oauth_states table if missing. + + OAuth state MUST be shared across Gunicorn workers / replicas; an + in-memory dict breaks multi-worker deployments (authorize hits worker + A, callback lands on worker B → Invalid state). + """ + if OAuthService._state_schema_ensured: + return + try: + cur.execute( + """ + CREATE TABLE IF NOT EXISTS qd_oauth_states ( + state VARCHAR(128) PRIMARY KEY, + provider VARCHAR(20) NOT NULL, + redirect TEXT, + created_at TIMESTAMP DEFAULT NOW(), + expires_at TIMESTAMP NOT NULL + ) + """ + ) + cur.execute( + "CREATE INDEX IF NOT EXISTS idx_oauth_states_expires ON qd_oauth_states(expires_at)" + ) + OAuthService._state_schema_ensured = True + except Exception as e: + logger.debug(f"_ensure_oauth_state_schema: {e}") + + def _oauth_state_save(self, state: str, provider: str, redirect: Optional[str]) -> None: + # Store naive UTC for TIMESTAMP WITHOUT TIME ZONE columns. + exp = ( + datetime.now(timezone.utc) + timedelta(minutes=self._oauth_state_ttl_minutes()) + ).replace(tzinfo=None) + red = (redirect or "").strip() if redirect else "" + with get_db_connection() as db: + cur = db.cursor() + self._ensure_oauth_state_schema(cur) + try: + cur.execute("DELETE FROM qd_oauth_states WHERE expires_at < NOW()") + except Exception: + pass + # IMPORTANT: include RETURNING state explicitly. Otherwise the + # PostgresCursor wrapper would auto-append RETURNING id, and this + # table has no "id" column → INSERT fails with UndefinedColumn. + cur.execute( + """ + INSERT INTO qd_oauth_states (state, provider, redirect, expires_at) + VALUES (?, ?, ?, ?) + ON CONFLICT (state) DO UPDATE + SET provider = EXCLUDED.provider, + redirect = EXCLUDED.redirect, + expires_at = EXCLUDED.expires_at + RETURNING state + """, + (state, provider, red, exp), + ) + try: + cur.fetchone() + except Exception: + pass + db.commit() + cur.close() + + def _oauth_state_peek_redirect(self, state: str) -> str: + if not state: + return "" + try: + with get_db_connection() as db: + cur = db.cursor() + self._ensure_oauth_state_schema(cur) + cur.execute( + "SELECT redirect FROM qd_oauth_states WHERE state = ? AND expires_at > NOW()", + (state,), + ) + row = cur.fetchone() + cur.close() + if not row: + return "" + return (row.get("redirect") or "").strip() + except Exception as e: + logger.warning(f"OAuth state peek failed: {e}") + return "" + + def _oauth_state_consume(self, state: str, provider: str) -> bool: + """Delete and validate state in one statement; True iff a row was removed.""" + if not state: + return False + try: + with get_db_connection() as db: + cur = db.cursor() + self._ensure_oauth_state_schema(cur) + cur.execute( + "DELETE FROM qd_oauth_states WHERE state = ? AND provider = ? AND expires_at > NOW()", + (state, provider), + ) + n = int(getattr(cur, "rowcount", 0) or 0) + db.commit() + cur.close() + return n > 0 + except Exception as e: + logger.error(f"OAuth state consume failed: {e}", exc_info=True) + return False def _load_config(self): """Load OAuth configuration from environment variables""" @@ -57,9 +169,6 @@ class OAuthService: if origin: self.allowed_redirect_origins.add(origin) - # State storage (in-memory for simplicity, could use Redis in production) - self._states = {} - @staticmethod def _normalize_origin(url: str) -> str: """Return scheme://host[:port] for a URL, empty string if invalid.""" @@ -82,10 +191,7 @@ class OAuthService: def peek_state_redirect(self, state: str) -> str: """Read the redirect URL associated with a pending OAuth state (no deletion).""" - if not state: - return '' - entry = self._states.get(state) or {} - return entry.get('redirect') or '' + return self._oauth_state_peek_redirect(state) # ========================================================================= # Google OAuth @@ -106,11 +212,15 @@ class OAuthService: return '', '' state = state or secrets.token_urlsafe(32) - state_data = {'provider': 'google', 'created_at': datetime.now()} + red = "" if redirect_url and self.is_redirect_allowed(redirect_url): - state_data['redirect'] = redirect_url - self._states[state] = state_data - + red = redirect_url.strip() + try: + self._oauth_state_save(state, "google", red or None) + except Exception as e: + logger.error(f"Failed to persist Google OAuth state: {e}") + return '', '' + params = { 'client_id': self.google_client_id, 'redirect_uri': self.google_redirect_uri, @@ -135,12 +245,9 @@ class OAuthService: Returns: (success, user_info_or_error) """ - # Validate state - if state not in self._states or self._states[state].get('provider') != 'google': + if not self._oauth_state_consume(state, "google"): return False, {'error': 'Invalid state parameter'} - - del self._states[state] - + try: # Exchange code for tokens token_response = requests.post( @@ -204,11 +311,15 @@ class OAuthService: return '', '' state = state or secrets.token_urlsafe(32) - state_data = {'provider': 'github', 'created_at': datetime.now()} + red = "" if redirect_url and self.is_redirect_allowed(redirect_url): - state_data['redirect'] = redirect_url - self._states[state] = state_data - + red = redirect_url.strip() + try: + self._oauth_state_save(state, "github", red or None) + except Exception as e: + logger.error(f"Failed to persist GitHub OAuth state: {e}") + return '', '' + params = { 'client_id': self.github_client_id, 'redirect_uri': self.github_redirect_uri, @@ -230,12 +341,9 @@ class OAuthService: Returns: (success, user_info_or_error) """ - # Validate state - if state not in self._states or self._states[state].get('provider') != 'github': + if not self._oauth_state_consume(state, "github"): return False, {'error': 'Invalid state parameter'} - - del self._states[state] - + try: # Exchange code for token token_response = requests.post( @@ -591,12 +699,16 @@ class OAuthService: # ========================================================================= def cleanup_expired_states(self, max_age_minutes: int = 10): - """Clean up expired OAuth states""" - cutoff = datetime.now() - from datetime import timedelta - cutoff = cutoff - timedelta(minutes=max_age_minutes) - - expired = [k for k, v in self._states.items() - if v.get('created_at', datetime.now()) < cutoff] - for k in expired: - del self._states[k] + """Delete expired OAuth state rows from the DB. + `max_age_minutes` is kept for backward-compat but is ignored — expiry + is driven by qd_oauth_states.expires_at which is set on insert. + """ + try: + with get_db_connection() as db: + cur = db.cursor() + self._ensure_oauth_state_schema(cur) + cur.execute("DELETE FROM qd_oauth_states WHERE expires_at < NOW()") + db.commit() + cur.close() + except Exception as e: + logger.debug(f"cleanup_expired_states: {e}") diff --git a/backend_api_python/app/services/usdt_payment_service.py b/backend_api_python/app/services/usdt_payment_service.py index 25bb62e..eed415e 100644 --- a/backend_api_python/app/services/usdt_payment_service.py +++ b/backend_api_python/app/services/usdt_payment_service.py @@ -21,6 +21,10 @@ logger = get_logger(__name__) class UsdtPaymentService: + # Class-level cache: only run DDL once per process to avoid taking schema + # locks on every request (also prevents long-held txns when the DB is busy). + _schema_ensured: bool = False + def __init__(self): self.billing = get_billing_service() @@ -44,7 +48,15 @@ class UsdtPaymentService: # -------------------- Schema -------------------- def _ensure_schema_best_effort(self, cur): - """Best-effort create table/columns for old databases.""" + """Best-effort create table/columns for old databases. + + Runs at most once per process (cached on the class) to avoid repeatedly + taking schema-level locks inside request/worker transactions, which on + a busy system contributes to `skipping vacuum --- lock not available` + and `idle in transaction` pressure. + """ + if UsdtPaymentService._schema_ensured: + return try: cur.execute( """ @@ -69,6 +81,7 @@ class UsdtPaymentService: cur.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_usdt_orders_address_unique ON qd_usdt_orders(chain, address)") cur.execute("CREATE INDEX IF NOT EXISTS idx_usdt_orders_user_id ON qd_usdt_orders(user_id)") cur.execute("CREATE INDEX IF NOT EXISTS idx_usdt_orders_status ON qd_usdt_orders(status)") + UsdtPaymentService._schema_ensured = True except Exception: pass @@ -175,6 +188,7 @@ class UsdtPaymentService: def get_order(self, user_id: int, order_id: int, refresh: bool = True) -> Tuple[bool, str, Dict[str, Any]]: try: + # Step 1: short read txn, release connection before any HTTP work with get_db_connection() as db: cur = db.cursor() self._ensure_schema_best_effort(cur) @@ -189,20 +203,30 @@ class UsdtPaymentService: (order_id, user_id), ) row = cur.fetchone() - if not row: - cur.close() - return False, "order_not_found", {} + cur.close() - if refresh: - logger.info( - "USDT get_order refresh order_id=%s user_id=%s status=%s", - order_id, - user_id, - (row.get("status") or ""), - ) - self._refresh_order_in_tx(cur, row) - db.commit() - # re-read + if not row: + return False, "order_not_found", {} + + # Step 2: optionally do chain check OUTSIDE the DB txn. TronGrid HTTP + # can take tens of seconds; holding a pool connection while waiting on + # the network is what used to produce `idle in transaction` and + # `skipping vacuum --- lock not available` on qd_usdt_orders. + if refresh: + logger.info( + "USDT get_order refresh order_id=%s user_id=%s status=%s", + order_id, + user_id, + (row.get("status") or ""), + ) + try: + self._refresh_one_order_out_of_tx(row) + except Exception as e: + logger.warning(f"get_order refresh (out-of-tx) failed order_id={order_id}: {e}") + + # Step 3: short read txn to return fresh state + with get_db_connection() as db: + cur = db.cursor() cur.execute( """ SELECT id, user_id, plan, chain, amount_usdt, address_index, address, status, tx_hash, @@ -213,8 +237,7 @@ class UsdtPaymentService: (order_id, user_id), ) row = cur.fetchone() - - cur.close() + cur.close() return True, "success", self._row_to_dict(row) except Exception as e: @@ -515,17 +538,21 @@ class UsdtPaymentService: def refresh_all_active_orders(self) -> int: """ Scan all pending/paid USDT orders and refresh their chain status. - Called by the background UsdtOrderWorker. - Returns the number of orders that were updated to 'confirmed' or 'expired'. + Each order is checked with a short read txn, then the HTTP call to + TronGrid is performed OUTSIDE of any DB transaction, and final updates + are applied in short write txns. This keeps pool connections from + sitting `idle in transaction` while TronGrid is slow/unreachable. + + Returns the number of orders whose status changed. """ updated = 0 cfg = self._get_cfg() try: + # Load candidate rows in one short read txn and release the connection. with get_db_connection() as db: cur = db.cursor() self._ensure_schema_best_effort(cur) - cur.execute( """ SELECT id, user_id, plan, chain, amount_usdt, address_index, address, status, tx_hash, @@ -537,38 +564,180 @@ class UsdtPaymentService: """ ) rows = cur.fetchall() or [] - logger.info( - "USDT reconcile batch start rows=%s debug_log=%s pay_enabled=%s", - len(rows), - cfg.get("debug_reconcile_log"), - cfg.get("enabled"), - ) - - for row in rows: - old_status = (row.get("status") or "").lower() - try: - self._refresh_order_in_tx(cur, row) - except Exception as e: - logger.debug(f"refresh_all: order {row.get('id')} error: {e}") - continue - - # Check if status changed - try: - cur.execute("SELECT status FROM qd_usdt_orders WHERE id = ?", (row["id"],)) - new_row = cur.fetchone() - new_status = (new_row.get("status") or "").lower() if new_row else old_status - if new_status != old_status: - updated += 1 - logger.info(f"USDT order {row['id']}: {old_status} -> {new_status}") - except Exception: - pass - - db.commit() cur.close() + + logger.info( + "USDT reconcile batch start rows=%s debug_log=%s pay_enabled=%s", + len(rows), + cfg.get("debug_reconcile_log"), + cfg.get("enabled"), + ) + + for row in rows: + order_id = row.get("id") + old_status = (row.get("status") or "").lower() + try: + self._refresh_one_order_out_of_tx(row) + except Exception as e: + logger.debug(f"refresh_all: order {order_id} error: {e}") + continue + + # Check if status changed (short read, new connection) + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute("SELECT status FROM qd_usdt_orders WHERE id = ?", (order_id,)) + new_row = cur.fetchone() + cur.close() + new_status = (new_row.get("status") or "").lower() if new_row else old_status + if new_status != old_status: + updated += 1 + logger.info(f"USDT order {order_id}: {old_status} -> {new_status}") + except Exception: + pass except Exception as e: logger.error(f"refresh_all_active_orders error: {e}", exc_info=True) return updated + # -------------------- Out-of-transaction refresh helpers -------------------- + + def _refresh_one_order_out_of_tx(self, row: Dict[str, Any]) -> None: + """Refresh a single order. HTTP is done here; DB writes are in short + txns only. Never hold a DB connection while calling TronGrid. + """ + cfg = self._get_cfg() + status = (row.get("status") or "").lower() + chain = (row.get("chain") or "").upper() + order_id = row.get("id") + now = datetime.now(timezone.utc) + + if chain != "TRC20": + logger.debug("USDT refresh skip order_id=%s reason=unsupported_chain chain=%s", order_id, chain) + return + if status not in ("pending", "paid", "expired"): + return + + address = row.get("address") or "" + amount = Decimal(str(row.get("amount_usdt") or 0)) + if not address or amount <= 0: + return + + # 'paid' just waits for confirm delay; no HTTP needed + if status == "paid": + confirm_sec = int(cfg.get("confirm_seconds") or 30) + paid_at = self._coerce_utc_datetime(row.get("paid_at")) + ready = False + if paid_at and (now - paid_at).total_seconds() >= confirm_sec: + ready = True + elif not paid_at and confirm_sec <= 0: + ready = True + if ready: + self._confirm_and_activate_short_tx( + order_id, row.get("user_id"), row.get("plan"), row.get("tx_hash") or "" + ) + return + + # pending / expired: TronGrid HTTP *outside* any DB txn + tx, chain_note = self._find_trc20_usdt_incoming(address, amount, row.get("created_at")) + + if not tx and chain_note and ( + chain_note.startswith("trongrid_http=") or chain_note.startswith("trongrid_request_error:") + ): + logger.warning( + "USDT reconcile TronGrid error order_id=%s user_id=%s %s", + order_id, row.get("user_id"), chain_note, + ) + if cfg.get("debug_reconcile_log"): + logger.info( + "USDT reconcile scan order_id=%s user_id=%s status=%s amount=%s addr=%s expires_at=%s note=%s", + order_id, + row.get("user_id"), + status, + amount, + address, + row.get("expires_at"), + chain_note if not tx else f"matched_tx={tx.get('transaction_id')}", + ) + + if tx: + tx_hash = tx.get("transaction_id") or "" + paid_at = datetime.now(timezone.utc) + # Short write txn: mark paid + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + "UPDATE qd_usdt_orders SET status = 'paid', tx_hash = ?, paid_at = ?, updated_at = NOW() " + "WHERE id = ? AND status IN ('pending','expired')", + (tx_hash, paid_at, order_id), + ) + db.commit() + cur.close() + except Exception as e: + logger.error(f"USDT mark_paid UPDATE failed order_id={order_id}: {e}") + return + + confirm_sec = int(cfg.get("confirm_seconds") or 30) + try: + tx_ts = tx.get("block_timestamp") + if tx_ts: + tx_time = datetime.fromtimestamp(int(tx_ts) / 1000.0, tz=timezone.utc) + if (now - tx_time).total_seconds() >= confirm_sec: + self._confirm_and_activate_short_tx(order_id, row.get("user_id"), row.get("plan"), tx_hash) + elif confirm_sec <= 0: + self._confirm_and_activate_short_tx(order_id, row.get("user_id"), row.get("plan"), tx_hash) + except Exception: + pass + return + + # No matching transfer yet: pending can transition to expired + if status == "pending": + exp = self._coerce_utc_datetime(row.get("expires_at")) + if exp is not None and exp <= now: + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + "UPDATE qd_usdt_orders SET status = 'expired', updated_at = NOW() " + "WHERE id = ? AND status = 'pending'", + (order_id,), + ) + db.commit() + cur.close() + except Exception as e: + logger.warning(f"USDT mark_expired UPDATE failed order_id={order_id}: {e}") + + def _confirm_and_activate_short_tx(self, order_id: int, user_id: int, plan: str, tx_hash: str) -> None: + """Idempotently mark confirmed in a short txn, then activate membership + (which opens its own DB connection). Never does HTTP inside a txn. + """ + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute("SELECT status FROM qd_usdt_orders WHERE id = ?", (order_id,)) + current = cur.fetchone() + if current and (current.get("status") or "").lower() == "confirmed": + cur.close() + return + cur.execute( + "UPDATE qd_usdt_orders SET status='confirmed', confirmed_at = NOW(), updated_at = NOW() " + "WHERE id = ? AND status IN ('paid','pending')", + (order_id,), + ) + db.commit() + cur.close() + except Exception as e: + logger.error(f"USDT confirm UPDATE failed order_id={order_id}: {e}") + return + + # Membership activation opens its own connection; keep it outside the + # confirm-txn above. + try: + ok, msg, _ = self.billing.purchase_membership(int(user_id), str(plan)) + logger.info(f"USDT activate membership: order={order_id} user={user_id} plan={plan} ok={ok} msg={msg}") + except Exception as e: + logger.error(f"USDT activate membership failed: order={order_id} err={e}", exc_info=True) + # ==================== Background Worker ==================== diff --git a/backend_api_python/app/utils/db_postgres.py b/backend_api_python/app/utils/db_postgres.py index b566550..e2d73fb 100644 --- a/backend_api_python/app/utils/db_postgres.py +++ b/backend_api_python/app/utils/db_postgres.py @@ -255,30 +255,86 @@ class PostgresCursor: return query def execute(self, query: str, args: Any = None): - """Execute SQL statement""" + """Execute SQL statement. + + For INSERT statements without an explicit RETURNING clause, we try to + append ``RETURNING id`` so legacy callers can read ``cursor.lastrowid``. + But not every table has an ``id`` column (e.g. ``qd_oauth_states`` + uses ``state`` as PK). In that case psycopg2 raises + ``UndefinedColumn`` and aborts the whole transaction, which can + cascade into "column \"id\" does not exist" errors across the app. + + To stay safe, wrap the RETURNING-id variant in a SAVEPOINT. If it + fails with UndefinedColumn, roll back to the savepoint and retry the + plain INSERT without RETURNING. The outer transaction is preserved. + """ query = self._convert_placeholders(query) - - # Check if this is an INSERT and add RETURNING id if not present + if args is not None and not isinstance(args, (tuple, list)): + args = (args,) + is_insert = query.strip().upper().startswith('INSERT') - if is_insert and 'RETURNING' not in query.upper(): - query = query.rstrip(';').rstrip() + ' RETURNING id' - + has_returning = 'RETURNING' in query.upper() + + if is_insert and not has_returning: + q_with_id = query.rstrip(';').rstrip() + ' RETURNING id' + savepoint = '_pg_ins_ret_id' + try: + self._cursor.execute(f"SAVEPOINT {savepoint}") + except Exception: + savepoint = None + + try: + if args: + result = self._cursor.execute(q_with_id, args) + else: + result = self._cursor.execute(q_with_id) + try: + row = self._cursor.fetchone() + if row and 'id' in row: + self._last_insert_id = row['id'] + except Exception: + pass + if savepoint: + try: + self._cursor.execute(f"RELEASE SAVEPOINT {savepoint}") + except Exception: + pass + return result + except Exception as e: + # If the error is about missing id column, fall back. Other + # errors (unique violation, NOT NULL, FK, ...) must propagate. + msg = str(e).lower() + is_missing_id = ( + 'column "id" does not exist' in msg + or 'undefinedcolumn' in e.__class__.__name__.lower() + and '"id"' in msg + ) + if not is_missing_id: + raise + if savepoint: + try: + self._cursor.execute(f"ROLLBACK TO SAVEPOINT {savepoint}") + except Exception: + pass + # Retry without RETURNING id. Leaves _last_insert_id as None. + if args: + return self._cursor.execute(query, args) + return self._cursor.execute(query) + + # Non-INSERT, or INSERT with caller-supplied RETURNING if args: - if not isinstance(args, (tuple, list)): - args = (args,) result = self._cursor.execute(query, args) else: result = self._cursor.execute(query) - - # Capture last insert id for INSERT statements - if is_insert: + + if is_insert and has_returning: try: row = self._cursor.fetchone() if row and 'id' in row: self._last_insert_id = row['id'] except Exception: pass - + return result def fetchone(self) -> Optional[Dict[str, Any]]: diff --git a/backend_api_python/env.example b/backend_api_python/env.example index 52bd6ff..3641821 100644 --- a/backend_api_python/env.example +++ b/backend_api_python/env.example @@ -26,6 +26,11 @@ FRONTEND_URL=http://localhost:8888 # allowed implicitly; list every additional front-end here. # Example: OAUTH_ALLOWED_REDIRECTS=https://m.quantdinger.com,https://app.quantdinger.com OAUTH_ALLOWED_REDIRECTS= +# OAuth CSRF state row lifetime in minutes (stored in Postgres so multiple +# Gunicorn workers / replicas share the same state; otherwise callbacks that +# land on a different worker than the authorize request will fail with +# "Invalid state parameter"). Clamped to [5, 120], default 20. +OAUTH_STATE_TTL_MINUTES=20 ENABLE_REGISTRATION=true # ========================= diff --git a/backend_api_python/migrations/init.sql b/backend_api_python/migrations/init.sql index b9ee310..c7530f3 100644 --- a/backend_api_python/migrations/init.sql +++ b/backend_api_python/migrations/init.sql @@ -97,6 +97,19 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_usdt_orders_address_unique ON qd_usdt_orde CREATE INDEX IF NOT EXISTS idx_usdt_orders_user_id ON qd_usdt_orders(user_id); CREATE INDEX IF NOT EXISTS idx_usdt_orders_status ON qd_usdt_orders(status); +-- ============================================================================= +-- 1.59. OAuth CSRF State (多 worker / 多实例共享,避免 Invalid state) +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS qd_oauth_states ( + state VARCHAR(128) PRIMARY KEY, + provider VARCHAR(20) NOT NULL, + redirect TEXT, + created_at TIMESTAMP DEFAULT NOW(), + expires_at TIMESTAMP NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_oauth_states_expires ON qd_oauth_states(expires_at); + -- ============================================================================= -- 1.6. Verification Codes (邮箱验证码) -- =============================================================================