from __future__ import annotations import hashlib import json from pathlib import Path from threading import RLock from typing import Any, Callable, TypeVar from sqlalchemy import Column, String, Text, create_engine, Integer, event, inspect, text from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker from services.storage.base import StorageBackend Base = declarative_base() ResultT = TypeVar("ResultT") class AccountModel(Base): """账号数据模型""" __tablename__ = "accounts" id = Column(Integer, primary_key=True, autoincrement=True) access_token = Column(String(2048), unique=True, nullable=False, index=True) owner_id = Column(String(255), nullable=True, index=True) shared = Column(Integer, nullable=False, default=0, index=True) status = Column(String(64), nullable=True, index=True) data = Column(Text, nullable=False) # JSON 格式存储完整账号数据 class AuthKeyModel(Base): """鉴权密钥数据模型""" __tablename__ = "auth_keys" id = Column(Integer, primary_key=True, autoincrement=True) key_id = Column(String(255), unique=True, nullable=False, index=True) data = Column(Text, nullable=False) class ShopStateModel(Base): __tablename__ = "shop_state" key = Column(String(64), primary_key=True) data = Column(Text, nullable=False) class RedeemCodeModel(Base): __tablename__ = "redeem_codes" id = Column(Integer, primary_key=True, autoincrement=True) code_id = Column(String(64), unique=True, nullable=False, index=True) code_hash = Column(String(128), unique=True, nullable=False, index=True) batch_id = Column(String(64), nullable=True, index=True) redeemed_by = Column(String(255), nullable=True, index=True) status = Column(String(32), nullable=False, index=True) data = Column(Text, nullable=False) class ShopLedgerModel(Base): __tablename__ = "shop_ledger" id = Column(Integer, primary_key=True, autoincrement=True) ledger_id = Column(String(64), unique=True, nullable=False, index=True) created_at = Column(String(64), nullable=True, index=True) user_id = Column(String(255), nullable=True, index=True) data = Column(Text, nullable=False) class DatabaseStorageBackend(StorageBackend): """数据库存储后端(支持 SQLite、PostgreSQL、MySQL 等)""" def __init__(self, database_url: str, *, table_scope: str = "all"): self.database_url = database_url self.table_scope = table_scope if table_scope in {"all", "accounts", "users"} else "all" connect_args: dict[str, Any] = {} if database_url.startswith("sqlite:///"): db_path = database_url.removeprefix("sqlite:///") if db_path and db_path != ":memory:": Path(db_path).parent.mkdir(parents=True, exist_ok=True) connect_args["check_same_thread"] = False connect_args["timeout"] = 30 self.engine = create_engine( database_url, pool_pre_ping=True, # 自动检测连接是否有效 pool_recycle=3600, # 1小时回收连接 connect_args=connect_args, ) if database_url.startswith("sqlite:///"): @event.listens_for(self.engine, "connect") def _configure_sqlite_connection(dbapi_connection: Any, _connection_record: Any) -> None: cursor = dbapi_connection.cursor() try: cursor.execute("PRAGMA busy_timeout=30000") cursor.execute("PRAGMA synchronous=FULL") cursor.execute("PRAGMA journal_mode=DELETE") finally: cursor.close() tables = None if self.table_scope == "accounts": tables = [AccountModel.__table__] elif self.table_scope == "users": tables = [ AuthKeyModel.__table__, ShopStateModel.__table__, RedeemCodeModel.__table__, ShopLedgerModel.__table__, ] Base.metadata.create_all(self.engine, tables=tables) self.Session = sessionmaker(bind=self.engine) self._lock = RLock() if self.table_scope in {"all", "accounts"}: self._migrate_account_query_columns() if self.table_scope in {"all", "users"}: self._migrate_legacy_redeem_codes() self._migrate_legacy_shop_ledger() def load_accounts(self) -> list[dict[str, Any]]: """从数据库加载账号数据""" session = self.Session() try: accounts = [] for row in session.query(AccountModel).all(): try: account_data = json.loads(row.data) if isinstance(account_data, dict): accounts.append(account_data) except json.JSONDecodeError: continue return accounts finally: session.close() @staticmethod def _account_owner_id(item: dict[str, Any]) -> str: return str(item.get("owner_id") or "").strip() @staticmethod def _account_shared(item: dict[str, Any]) -> int: return 1 if bool(item.get("shared")) else 0 @staticmethod def _account_status(item: dict[str, Any]) -> str: return str(item.get("status") or "").strip() def _account_from_row(self, row: AccountModel) -> dict[str, Any] | None: try: account_data = json.loads(row.data) except json.JSONDecodeError: return None return account_data if isinstance(account_data, dict) else None def _apply_account_row(self, row: AccountModel, item: dict[str, Any]) -> None: row.access_token = str(item.get("access_token") or "").strip() row.owner_id = self._account_owner_id(item) or None row.shared = self._account_shared(item) row.status = self._account_status(item) or None row.data = json.dumps(item, ensure_ascii=False) def _load_account_rows(self, query: Any) -> list[dict[str, Any]]: accounts: list[dict[str, Any]] = [] for row in query.all(): account_data = self._account_from_row(row) if account_data is not None: accounts.append(account_data) return accounts def load_public_accounts(self) -> list[dict[str, Any]]: session = self.Session() try: return self._load_account_rows( session.query(AccountModel).filter( (AccountModel.owner_id.is_(None)) | (AccountModel.owner_id == "") ) ) finally: session.close() def load_shared_accounts(self) -> list[dict[str, Any]]: session = self.Session() try: return self._load_account_rows( session.query(AccountModel).filter( AccountModel.owner_id.isnot(None), AccountModel.owner_id != "", AccountModel.shared == 1, ) ) finally: session.close() def load_accounts_by_owner(self, owner_id: str) -> list[dict[str, Any]]: normalized_owner = str(owner_id or "").strip() if not normalized_owner: return [] session = self.Session() try: return self._load_account_rows(session.query(AccountModel).filter_by(owner_id=normalized_owner)) finally: session.close() def load_account(self, access_token: str) -> dict[str, Any] | None: normalized_token = str(access_token or "").strip() if not normalized_token: return None session = self.Session() try: row = session.query(AccountModel).filter_by(access_token=normalized_token).first() return self._account_from_row(row) if row is not None else None finally: session.close() def upsert_accounts(self, accounts: list[dict[str, Any]]) -> None: with self._lock: session = self.Session() try: self._acquire_transaction_lock(session, "accounts") for item in accounts: if not isinstance(item, dict): continue access_token = str(item.get("access_token") or "").strip() if not access_token: continue row = session.query(AccountModel).filter_by(access_token=access_token).first() if row is None: row = AccountModel(access_token=access_token, data="{}") session.add(row) self._apply_account_row(row, item) session.commit() except Exception as e: session.rollback() raise e finally: session.close() def delete_accounts_by_tokens( self, tokens: list[str], *, owner_id: str = "", admin: bool = True, ) -> None: normalized_tokens = [str(token or "").strip() for token in dict.fromkeys(tokens) if str(token or "").strip()] if not normalized_tokens: return normalized_owner = str(owner_id or "").strip() with self._lock: session = self.Session() try: self._acquire_transaction_lock(session, "accounts") query = session.query(AccountModel).filter(AccountModel.access_token.in_(normalized_tokens)) if not admin: query = query.filter_by(owner_id=normalized_owner) query.delete(synchronize_session=False) session.commit() except Exception as e: session.rollback() raise e finally: session.close() def save_accounts(self, accounts: list[dict[str, Any]]) -> None: """保存账号数据到数据库""" with self._lock: session = self.Session() try: self._acquire_transaction_lock(session, "accounts") session.query(AccountModel).delete() for item in accounts: if not isinstance(item, dict): continue access_token = str(item.get("access_token") or "").strip() if not access_token: continue row = AccountModel(access_token=access_token, data="{}") self._apply_account_row(row, item) session.add(row) session.commit() except Exception as e: session.rollback() raise e finally: session.close() def load_auth_keys(self) -> list[dict[str, Any]]: """从数据库加载鉴权密钥数据""" return self._load_rows(AuthKeyModel) def save_auth_keys(self, auth_keys: list[dict[str, Any]]) -> None: """保存鉴权密钥数据到数据库""" with self._lock: self._save_rows(AuthKeyModel, auth_keys, "id", "key_id") def merge_auth_keys(self, changed_items: list[dict[str, Any]], deleted_ids: list[str]) -> None: """Apply auth-key changes without rewriting unchanged rows. This is safer for shared PostgreSQL deployments where multiple Spaces can update different users at the same time. """ with self._lock: session = self.Session() try: self._acquire_transaction_lock(session, "auth_keys") for key_id in {str(item or "").strip() for item in deleted_ids if str(item or "").strip()}: session.query(AuthKeyModel).filter_by(key_id=key_id).delete() for item in changed_items: if not isinstance(item, dict): continue key_id = str(item.get("id") or "").strip() if not key_id: continue payload = json.dumps(item, ensure_ascii=False) row = session.query(AuthKeyModel).filter_by(key_id=key_id).first() if row is None: session.add(AuthKeyModel(key_id=key_id, data=payload)) else: row.data = payload session.commit() except Exception as e: session.rollback() raise e finally: session.close() def update_auth_keys_locked( self, mutator: Callable[[list[dict[str, Any]]], tuple[list[dict[str, Any]], ResultT]], ) -> ResultT: """Load, mutate, and save auth keys under one database transaction lock.""" with self._lock: session = self.Session() try: self._acquire_transaction_lock(session, "auth_keys") rows = session.query(AuthKeyModel).all() items: list[dict[str, Any]] = [] for row in rows: try: item_data = json.loads(row.data) except json.JSONDecodeError: continue if isinstance(item_data, dict): items.append(item_data) next_items, result = mutator(items) normalized_items = [item for item in next_items if isinstance(item, dict)] next_ids = {str(item.get("id") or "").strip() for item in normalized_items} next_ids.discard("") for row in rows: if str(row.key_id or "").strip() not in next_ids: session.delete(row) existing_by_id = {str(row.key_id or "").strip(): row for row in rows} for item in normalized_items: key_id = str(item.get("id") or "").strip() if not key_id: continue payload = json.dumps(item, ensure_ascii=False) row = existing_by_id.get(key_id) if row is None: session.add(AuthKeyModel(key_id=key_id, data=payload)) elif row.data != payload: # ⚠️⚠️ 只写真正变化的行。之前是无条件赋值,等于每次「改一条」都 # 把整张表(几百上千行)标记为 dirty 后逐行 UPDATE + commit。 # 图片任务线程并发结算积分时,这一步会把 SQLite 写锁占住几百毫秒, # 而事件循环线程也在等这把锁 ⇒ 整站 HTTP 卡死(2026-10-19 事故)。 row.data = payload session.commit() return result except Exception as e: session.rollback() raise e finally: session.close() @staticmethod def _loads_dict(value: str | None) -> dict[str, Any]: try: data = json.loads(value or "{}") except json.JSONDecodeError: return {} return data if isinstance(data, dict) else {} @staticmethod def _compact_shop_state(state: dict[str, Any]) -> dict[str, Any]: compact = dict(state if isinstance(state, dict) else {}) codes = compact.get("codes") if isinstance(compact.get("codes"), list) else [] compact["codes"] = [ item for item in codes if isinstance(item, dict) and not (str(item.get("id") or "").strip() and str(item.get("code_hash") or "").strip()) ] # Redeem codes and the ledger are stored in their own indexed tables. # Keep only malformed legacy values here so an upgrade cannot discard # data it cannot yet identify. compact["ledger"] = [] return compact @staticmethod def _code_identity(item: dict[str, Any]) -> tuple[str, str]: return str(item.get("id") or "").strip(), str(item.get("code_hash") or "").strip() def _load_shop_state_from_session(self, session: Any, key: str = "default") -> dict[str, Any]: row = session.query(ShopStateModel).filter_by(key=str(key or "").strip() or "default").first() return self._loads_dict(row.data if row is not None else None) def _save_shop_state_to_session(self, session: Any, key: str, state: dict[str, Any]) -> None: normalized_key = str(key or "").strip() or "default" payload_state = self._compact_shop_state(state) if normalized_key == "default" else state self._save_raw_shop_state_to_session(session, normalized_key, payload_state) def _save_raw_shop_state_to_session(self, session: Any, key: str, state: dict[str, Any]) -> None: normalized_key = str(key or "").strip() or "default" payload = json.dumps(state if isinstance(state, dict) else {}, ensure_ascii=False) row = session.query(ShopStateModel).filter_by(key=normalized_key).first() if row is None: session.add(ShopStateModel(key=normalized_key, data=payload)) else: row.data = payload def _redeem_code_from_row(self, row: RedeemCodeModel) -> dict[str, Any]: item = self._loads_dict(row.data) item.setdefault("id", row.code_id) item.setdefault("code_hash", row.code_hash) item.setdefault("status", row.status) if row.batch_id and not item.get("batch_id"): item["batch_id"] = row.batch_id if row.redeemed_by and not item.get("redeemed_by"): item["redeemed_by"] = row.redeemed_by return item def _load_redeem_codes_from_session(self, session: Any) -> list[dict[str, Any]]: items: list[dict[str, Any]] = [] for row in session.query(RedeemCodeModel).order_by(RedeemCodeModel.id.asc()).all(): item = self._redeem_code_from_row(row) code_id, code_hash = self._code_identity(item) if code_id and code_hash: items.append(item) return items def _apply_redeem_code_row(self, row: RedeemCodeModel, item: dict[str, Any]) -> None: code_id, code_hash = self._code_identity(item) batch_id = str(item.get("batch_id") or "").strip() or None redeemed_by = str(item.get("redeemed_by") or "").strip() or None status = str(item.get("status") or "active").strip() or "active" payload = json.dumps(item, ensure_ascii=False) if ( row.code_id == code_id and row.code_hash == code_hash and row.batch_id == batch_id and row.redeemed_by == redeemed_by and row.status == status and row.data == payload ): return row.code_id = code_id row.code_hash = code_hash row.batch_id = batch_id row.redeemed_by = redeemed_by row.status = status row.data = payload def _sync_redeem_codes_to_session(self, session: Any, codes: list[dict[str, Any]]) -> None: normalized_items: list[dict[str, Any]] = [] seen_hashes: set[str] = set() for item in codes: if not isinstance(item, dict): continue code_id, code_hash = self._code_identity(item) if not code_id or not code_hash or code_hash in seen_hashes: continue seen_hashes.add(code_hash) normalized_items.append(dict(item)) rows = session.query(RedeemCodeModel).all() existing_by_hash = {str(row.code_hash or "").strip(): row for row in rows} next_hashes = {str(item.get("code_hash") or "").strip() for item in normalized_items} for row in rows: if str(row.code_hash or "").strip() not in next_hashes: session.delete(row) for item in normalized_items: code_hash = str(item.get("code_hash") or "").strip() row = existing_by_hash.get(code_hash) if row is None: row = RedeemCodeModel( code_id=str(item.get("id") or "").strip(), code_hash=code_hash, status=str(item.get("status") or "active").strip() or "active", data="{}", ) session.add(row) self._apply_redeem_code_row(row, item) @staticmethod def _ledger_identity(item: dict[str, Any], position: int) -> str: ledger_id = str(item.get("id") or "").strip() if ledger_id: return ledger_id payload = json.dumps(item, ensure_ascii=False, sort_keys=True, separators=(",", ":")) digest = hashlib.sha256(f"{position}:{payload}".encode("utf-8")).hexdigest()[:24] return f"legacy-{digest}" def _normalize_ledger_items(self, ledger: list[dict[str, Any]]) -> list[dict[str, Any]]: normalized: list[dict[str, Any]] = [] seen_ids: set[str] = set() for position, item in enumerate(ledger): if not isinstance(item, dict): continue normalized_item = dict(item) ledger_id = self._ledger_identity(normalized_item, position) if ledger_id in seen_ids: duplicate_digest = hashlib.sha256( f"{ledger_id}:{position}".encode("utf-8") ).hexdigest()[:12] ledger_id = f"{ledger_id[:48]}-{duplicate_digest}" seen_ids.add(ledger_id) normalized_item["id"] = ledger_id normalized.append(normalized_item) return normalized def _ledger_from_row(self, row: ShopLedgerModel) -> dict[str, Any]: item = self._loads_dict(row.data) item.setdefault("id", row.ledger_id) if row.created_at and not item.get("time"): item["time"] = row.created_at if row.user_id and not item.get("user_id"): item["user_id"] = row.user_id return item def _load_shop_ledger_from_session(self, session: Any) -> list[dict[str, Any]]: return [ self._ledger_from_row(row) for row in session.query(ShopLedgerModel).order_by(ShopLedgerModel.id.asc()).all() ] def _apply_shop_ledger_row(self, row: ShopLedgerModel, item: dict[str, Any]) -> None: payload = json.dumps(item, ensure_ascii=False) ledger_id = str(item["id"]) created_at = str(item.get("time") or "").strip() or None user_id = str(item.get("user_id") or "").strip() or None if ( row.ledger_id == ledger_id and row.created_at == created_at and row.user_id == user_id and row.data == payload ): return row.ledger_id = ledger_id row.created_at = created_at row.user_id = user_id row.data = payload def _sync_shop_ledger_to_session(self, session: Any, ledger: list[dict[str, Any]]) -> None: normalized_items = self._normalize_ledger_items(ledger) rows = session.query(ShopLedgerModel).all() existing_by_id = {str(row.ledger_id or "").strip(): row for row in rows} next_ids = {str(item["id"]) for item in normalized_items} for row in rows: if str(row.ledger_id or "").strip() not in next_ids: session.delete(row) for item in normalized_items: ledger_id = str(item["id"]) row = existing_by_id.get(ledger_id) if row is None: row = ShopLedgerModel(ledger_id=ledger_id, data="{}") session.add(row) self._apply_shop_ledger_row(row, item) def _append_shop_ledger_to_session(self, session: Any, entries: list[dict[str, Any]]) -> None: """Append ledger entries without scanning or rewriting prior history.""" for item in self._normalize_ledger_items(entries): ledger_id = str(item["id"]) row = session.query(ShopLedgerModel).filter_by(ledger_id=ledger_id).first() if row is None: row = ShopLedgerModel(ledger_id=ledger_id, data="{}") session.add(row) self._apply_shop_ledger_row(row, item) def _load_default_shop_state_from_session(self, session: Any) -> dict[str, Any]: state = self._load_shop_state_from_session(session, "default") legacy_codes = state.get("codes") if isinstance(state.get("codes"), list) else [] state["codes"] = [item for item in legacy_codes if isinstance(item, dict)] + self._load_redeem_codes_from_session(session) state["ledger"] = self._load_shop_ledger_from_session(session) return state def _migrate_account_query_columns(self) -> None: with self._lock: session = self.Session() try: existing_columns = {column["name"] for column in inspect(self.engine).get_columns("accounts")} column_statements = { "owner_id": "ALTER TABLE accounts ADD COLUMN owner_id VARCHAR(255)", "shared": "ALTER TABLE accounts ADD COLUMN shared INTEGER NOT NULL DEFAULT 0", "status": "ALTER TABLE accounts ADD COLUMN status VARCHAR(64)", } for column_name, statement in column_statements.items(): if column_name not in existing_columns: try: session.execute(text(statement)) session.commit() except Exception as exc: session.rollback() refreshed_columns = { column["name"] for column in inspect(self.engine).get_columns("accounts") } if column_name not in refreshed_columns: print(f"[storage] account query column migration skipped: {exc}") return except Exception as exc: session.rollback() print(f"[storage] account query column migration skipped: {exc}") return finally: session.close() with self._lock: session = self.Session() try: for statement in ( "CREATE INDEX IF NOT EXISTS ix_accounts_owner_id ON accounts (owner_id)", "CREATE INDEX IF NOT EXISTS ix_accounts_shared ON accounts (shared)", "CREATE INDEX IF NOT EXISTS ix_accounts_status ON accounts (status)", ): try: session.execute(text(statement)) except Exception as exc: print(f"[storage] account query index migration skipped: {exc}") rows = session.query(AccountModel).all() for row in rows: item = self._account_from_row(row) if item is not None: self._apply_account_row(row, item) session.commit() except Exception as exc: session.rollback() print(f"[storage] account query metadata backfill skipped: {exc}") finally: session.close() def _migrate_legacy_redeem_codes(self) -> None: with self._lock: session = self.Session() try: self._acquire_transaction_lock(session, "shop_state") self._acquire_transaction_lock(session, "redeem_codes") state = self._load_shop_state_from_session(session, "default") legacy_codes = state.get("codes") if isinstance(state.get("codes"), list) else [] migrated = 0 for item in legacy_codes: if not isinstance(item, dict): continue code_id, code_hash = self._code_identity(item) if not code_id or not code_hash: continue row = session.query(RedeemCodeModel).filter_by(code_hash=code_hash).first() if row is not None: continue row = RedeemCodeModel(code_id=code_id, code_hash=code_hash, status="active", data="{}") self._apply_redeem_code_row(row, dict(item)) session.add(row) migrated += 1 if legacy_codes: state["codes"] = [ item for item in legacy_codes if not ( isinstance(item, dict) and str(item.get("id") or "").strip() and str(item.get("code_hash") or "").strip() ) ] self._save_raw_shop_state_to_session(session, "default", state) session.commit() if migrated: print(f"[storage] Migrated {migrated} redeem codes into indexed table") except Exception as e: session.rollback() print(f"[storage] redeem code migration skipped: {e}") finally: session.close() def _migrate_legacy_shop_ledger(self) -> None: with self._lock: session = self.Session() try: self._acquire_transaction_lock(session, "shop_state") self._acquire_transaction_lock(session, "shop_ledger") state = self._load_shop_state_from_session(session, "default") legacy_ledger = state.get("ledger") if isinstance(state.get("ledger"), list) else [] if not legacy_ledger: session.commit() return existing_ledger = self._load_shop_ledger_from_session(session) combined_ledger = [*existing_ledger, *legacy_ledger] self._sync_shop_ledger_to_session(session, combined_ledger) state["ledger"] = [] self._save_raw_shop_state_to_session(session, "default", state) session.commit() print(f"[storage] Migrated {len(legacy_ledger)} shop ledger entries into indexed table") except Exception as exc: session.rollback() print(f"[storage] shop ledger migration skipped: {exc}") finally: session.close() def load_redeem_codes(self) -> list[dict[str, Any]]: session = self.Session() try: return self._load_redeem_codes_from_session(session) finally: session.close() def load_shop_ledger(self) -> list[dict[str, Any]]: session = self.Session() try: return self._load_shop_ledger_from_session(session) finally: session.close() def append_shop_ledger(self, entries: list[dict[str, Any]]) -> None: """Append billing history without loading or rewriting prior entries.""" normalized_entries = [item for item in entries if isinstance(item, dict)] if not normalized_entries: return with self._lock: session = self.Session() try: self._acquire_transaction_lock(session, "shop_ledger") self._append_shop_ledger_to_session(session, normalized_entries) session.commit() except Exception as exc: session.rollback() raise exc finally: session.close() def get_redeem_code_by_hash(self, code_hash: str) -> dict[str, Any] | None: normalized_hash = str(code_hash or "").strip() if not normalized_hash: return None session = self.Session() try: row = session.query(RedeemCodeModel).filter_by(code_hash=normalized_hash).first() return self._redeem_code_from_row(row) if row is not None else None finally: session.close() def has_redeemed_code_in_batch(self, batch_id: str, user_id: str) -> bool: normalized_batch = str(batch_id or "").strip() normalized_user = str(user_id or "").strip() if not normalized_batch or not normalized_user: return False session = self.Session() try: return ( session.query(RedeemCodeModel) .filter_by(batch_id=normalized_batch, redeemed_by=normalized_user, status="redeemed") .first() is not None ) finally: session.close() def update_redeem_codes_locked( self, mutator: Callable[[dict[str, Any]], tuple[dict[str, Any], ResultT]], ) -> ResultT: """Mutate redeem codes and append ledger entries without touching shop_state.""" with self._lock: session = self.Session() try: self._acquire_transaction_lock(session, "redeem_codes") self._acquire_transaction_lock(session, "shop_ledger") state = { "codes": self._load_redeem_codes_from_session(session), "ledger": [], } next_state, result = mutator(state) next_state = next_state if isinstance(next_state, dict) else {} if "codes" not in next_state: next_state["codes"] = state.get("codes", []) next_codes = next_state.get("codes") if isinstance(next_state.get("codes"), list) else [] next_ledger = next_state.get("ledger") if isinstance(next_state.get("ledger"), list) else [] self._sync_redeem_codes_to_session(session, next_codes) self._append_shop_ledger_to_session(session, next_ledger) session.commit() return result except Exception as e: session.rollback() raise e finally: session.close() def delete_redeem_codes_locked( self, code_ids: list[str], mutator: Callable[[list[dict[str, Any]]], tuple[list[dict[str, Any]], ResultT]], ) -> ResultT: """Validate and delete selected redeem codes in one transaction.""" normalized_ids = list( dict.fromkeys( code_id for value in code_ids if (code_id := str(value or "").strip()) ) ) with self._lock: session = self.Session() try: self._acquire_transaction_lock(session, "redeem_codes") self._acquire_transaction_lock(session, "shop_ledger") rows: list[RedeemCodeModel] = [] for offset in range(0, len(normalized_ids), 500): chunk = normalized_ids[offset:offset + 500] query = session.query(RedeemCodeModel).filter(RedeemCodeModel.code_id.in_(chunk)) if self._is_postgresql: query = query.with_for_update() rows.extend(query.all()) selected_items: list[dict[str, Any]] = [] for row in rows: item = self._redeem_code_from_row(row) # Indexed columns are authoritative for identity and status. # This also keeps the redeemed guard safe if legacy JSON data # is stale or partially inconsistent. item["id"] = row.code_id item["status"] = row.status selected_items.append(item) ledger_entries, result = mutator(selected_items) for row in rows: session.delete(row) self._append_shop_ledger_to_session(session, ledger_entries) session.commit() return result except Exception: session.rollback() raise finally: session.close() def update_user_and_redeem_code_locked( self, *, code_hash: str, user_id: str, mutator: Callable[ [dict[str, Any] | None, dict[str, Any] | None, list[dict[str, Any]], dict[str, Any]], tuple[dict[str, Any] | None, dict[str, Any] | None, dict[str, Any], ResultT], ], ) -> ResultT: """Redeem one code atomically without reading or rewriting shop_state.""" normalized_hash = str(code_hash or "").strip() normalized_user = str(user_id or "").strip() with self._lock: session = self.Session() try: self._acquire_transaction_lock(session, "auth_keys") self._acquire_transaction_lock(session, "redeem_codes") self._acquire_transaction_lock(session, "shop_ledger") auth_row = session.query(AuthKeyModel).filter_by(key_id=normalized_user).first() auth_item = self._loads_dict(auth_row.data) if auth_row is not None else None code_row = session.query(RedeemCodeModel).filter_by(code_hash=normalized_hash).first() code_item = self._redeem_code_from_row(code_row) if code_row is not None else None batch_items: list[dict[str, Any]] = [] batch_id = str((code_item or {}).get("batch_id") or "").strip() if batch_id and normalized_user: batch_rows = ( session.query(RedeemCodeModel) .filter_by(batch_id=batch_id, redeemed_by=normalized_user, status="redeemed") .all() ) batch_items = [self._redeem_code_from_row(row) for row in batch_rows] state = {"codes": [], "ledger": []} next_auth_item, next_code_item, next_state, result = mutator(auth_item, code_item, batch_items, state) if next_auth_item is not None: key_id = str(next_auth_item.get("id") or "").strip() if not key_id: raise ValueError("用户不存在") payload = json.dumps(next_auth_item, ensure_ascii=False) if auth_row is None: session.add(AuthKeyModel(key_id=key_id, data=payload)) else: auth_row.data = payload if next_code_item is not None: next_code_id, next_code_hash = self._code_identity(next_code_item) if not next_code_id or not next_code_hash: raise ValueError("卡密不存在") if code_row is None: code_row = RedeemCodeModel( code_id=next_code_id, code_hash=next_code_hash, status=str(next_code_item.get("status") or "active").strip() or "active", data="{}", ) session.add(code_row) self._apply_redeem_code_row(code_row, next_code_item) normalized_next_state = next_state if isinstance(next_state, dict) else {} next_ledger = normalized_next_state.get("ledger") if isinstance(next_ledger, list): self._append_shop_ledger_to_session(session, next_ledger) session.commit() return result except Exception as e: session.rollback() raise e finally: session.close() def load_shop_state(self) -> dict[str, Any]: return self.load_named_state("default") def load_named_state(self, key: str) -> dict[str, Any]: session = self.Session() try: normalized_key = str(key or "").strip() or "default" if normalized_key == "default": return self._load_default_shop_state_from_session(session) return self._load_shop_state_from_session(session, normalized_key) finally: session.close() def save_shop_state(self, state: dict[str, Any]) -> None: self.save_named_state("default", state) def save_named_state(self, key: str, state: dict[str, Any]) -> None: with self._lock: session = self.Session() try: normalized_key = str(key or "").strip() or "default" if normalized_key == "default": if isinstance(state.get("codes"), list): self._sync_redeem_codes_to_session(session, state["codes"]) if isinstance(state.get("ledger"), list): self._sync_shop_ledger_to_session(session, state["ledger"]) self._save_shop_state_to_session(session, normalized_key, state) session.commit() except Exception as e: session.rollback() raise e finally: session.close() def update_shop_state_locked(self, mutator: Callable[[dict[str, Any]], tuple[dict[str, Any], ResultT]]) -> ResultT: """Load, mutate, and save shop state under one database transaction lock.""" with self._lock: session = self.Session() try: self._acquire_transaction_lock(session, "shop_state") self._acquire_transaction_lock(session, "redeem_codes") self._acquire_transaction_lock(session, "shop_ledger") state = self._load_default_shop_state_from_session(session) next_state, result = mutator(state) next_state = next_state if isinstance(next_state, dict) else {} if "codes" not in next_state: next_state["codes"] = state.get("codes", []) if "ledger" not in next_state: next_state["ledger"] = state.get("ledger", []) next_codes = next_state.get("codes") if isinstance(next_state.get("codes"), list) else [] next_ledger = next_state.get("ledger") if isinstance(next_state.get("ledger"), list) else [] self._sync_redeem_codes_to_session(session, next_codes) self._sync_shop_ledger_to_session(session, next_ledger) self._save_shop_state_to_session(session, "default", next_state) session.commit() return result except Exception as e: session.rollback() raise e finally: session.close() def update_user_and_shop_state_locked( self, mutator: Callable[[list[dict[str, Any]], dict[str, Any]], tuple[list[dict[str, Any]], dict[str, Any], ResultT]], ) -> ResultT: """Load, mutate, and save auth keys plus shop state in one transaction.""" with self._lock: session = self.Session() try: self._acquire_transaction_lock(session, "shop_state") self._acquire_transaction_lock(session, "auth_keys") self._acquire_transaction_lock(session, "redeem_codes") self._acquire_transaction_lock(session, "shop_ledger") auth_rows = session.query(AuthKeyModel).all() auth_items: list[dict[str, Any]] = [] for row in auth_rows: try: item_data = json.loads(row.data) except json.JSONDecodeError: continue if isinstance(item_data, dict): auth_items.append(item_data) shop_state = self._load_default_shop_state_from_session(session) next_auth_items, next_shop_state, result = mutator(auth_items, shop_state) next_shop_state = next_shop_state if isinstance(next_shop_state, dict) else {} if "codes" not in next_shop_state: next_shop_state["codes"] = shop_state.get("codes", []) if "ledger" not in next_shop_state: next_shop_state["ledger"] = shop_state.get("ledger", []) normalized_auth_items = [item for item in next_auth_items if isinstance(item, dict)] next_ids = {str(item.get("id") or "").strip() for item in normalized_auth_items} next_ids.discard("") for row in auth_rows: if str(row.key_id or "").strip() not in next_ids: session.delete(row) existing_by_id = {str(row.key_id or "").strip(): row for row in auth_rows} for item in normalized_auth_items: key_id = str(item.get("id") or "").strip() if not key_id: continue payload = json.dumps(item, ensure_ascii=False) row = existing_by_id.get(key_id) if row is None: session.add(AuthKeyModel(key_id=key_id, data=payload)) else: row.data = payload next_codes = next_shop_state.get("codes") if isinstance(next_shop_state.get("codes"), list) else [] self._sync_redeem_codes_to_session(session, next_codes) next_ledger = next_shop_state.get("ledger") if isinstance(next_shop_state.get("ledger"), list) else [] self._sync_shop_ledger_to_session(session, next_ledger) self._save_shop_state_to_session(session, "default", next_shop_state) session.commit() return result except Exception as e: session.rollback() raise e finally: session.close() def _load_rows(self, model: type[AccountModel] | type[AuthKeyModel]) -> list[dict[str, Any]]: session = self.Session() try: items = [] for row in session.query(model).all(): try: item_data = json.loads(row.data) if isinstance(item_data, dict): items.append(item_data) except json.JSONDecodeError: continue return items finally: session.close() def _save_rows( self, model: type[AccountModel] | type[AuthKeyModel], items: list[dict[str, Any]], source_key: str, target_key: str | None = None, ) -> None: session = self.Session() try: session.query(model).delete() for item in items: if not isinstance(item, dict): continue key_value = str(item.get(source_key) or "").strip() if not key_value: continue session.add( model( **{target_key or source_key: key_value}, data=json.dumps(item, ensure_ascii=False), ) ) session.commit() except Exception as e: session.rollback() raise e finally: session.close() @property def _is_postgresql(self) -> bool: normalized = self.database_url.lower() return normalized.startswith("postgresql://") or normalized.startswith("postgres://") @staticmethod def _lock_id(name: str) -> int: digest = hashlib.sha256(f"chatsam:{name}".encode("utf-8")).digest() return int.from_bytes(digest[:8], "big") & 0x7FFF_FFFF_FFFF_FFFF def _acquire_transaction_lock(self, session: Any, name: str) -> None: if not self._is_postgresql: return session.execute(text("SELECT pg_advisory_xact_lock(:lock_id)"), {"lock_id": self._lock_id(name)}) def health_check(self) -> dict[str, Any]: """健康检查""" try: session = self.Session() try: # 尝试执行简单查询 session.execute(text("SELECT 1")) result: dict[str, Any] = { "status": "healthy", "backend": "database", "database_url": self._mask_password(self.database_url), "table_scope": self.table_scope, } if self.table_scope in {"all", "accounts"}: result["account_count"] = session.query(AccountModel).count() if self.table_scope in {"all", "users"}: result["auth_key_count"] = session.query(AuthKeyModel).count() result["shop_state_count"] = session.query(ShopStateModel).count() result["redeem_code_count"] = session.query(RedeemCodeModel).count() result["shop_ledger_count"] = session.query(ShopLedgerModel).count() return result finally: session.close() except Exception as e: return { "status": "unhealthy", "backend": "database", "error": str(e), } def get_backend_info(self) -> dict[str, Any]: """获取存储后端信息""" db_type = "unknown" if "sqlite" in self.database_url: db_type = "sqlite" elif "postgresql" in self.database_url or "postgres" in self.database_url: db_type = "postgresql" elif "mysql" in self.database_url: db_type = "mysql" return { "type": "database", "db_type": db_type, "table_scope": self.table_scope, "description": f"数据库存储 ({db_type})", "database_url": self._mask_password(self.database_url), } @staticmethod def _mask_password(url: str) -> str: """隐藏数据库连接字符串中的密码""" if "://" not in url: return url try: protocol, rest = url.split("://", 1) if "@" in rest: credentials, host = rest.split("@", 1) if ":" in credentials: username, _ = credentials.split(":", 1) return f"{protocol}://{username}:****@{host}" return url except Exception: return url