from __future__ import annotations import hashlib import hmac import secrets import time import uuid from datetime import datetime, timedelta, timezone from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from threading import RLock, Thread from typing import Callable, Literal, TypeVar try: from zoneinfo import ZoneInfo except ImportError: # pragma: no cover - Python 3.9+ normally has zoneinfo. ZoneInfo = None # type: ignore[assignment] from services.config import config from services.storage.base import StorageBackend AuthRole = Literal["admin", "user", "normal"] ResultT = TypeVar("ResultT") THIRD_PARTY_IMAGE_GROUP_WALLET_MODEL_ID = "__group__" DAILY_GIFT_ACCOUNT_POOL_MODEL_ID = "__account_pool__" def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() def _today_key() -> str: tz = ZoneInfo("Asia/Shanghai") if ZoneInfo else timezone(timedelta(hours=8)) return datetime.now(tz).strftime("%Y-%m-%d") def _china_now() -> datetime: tz = ZoneInfo("Asia/Shanghai") if ZoneInfo else timezone(timedelta(hours=8)) return datetime.now(tz) def _period_keys(now: datetime | None = None) -> tuple[str, str, str]: current = now or _china_now() date_key = current.strftime("%Y-%m-%d") iso_year, iso_week, _ = current.isocalendar() return date_key, f"{iso_year}-W{iso_week:02d}", current.strftime("%Y-%m") def _hash_key(value: str) -> str: return hashlib.sha256(value.encode("utf-8")).hexdigest() def _invite_code() -> str: return secrets.token_urlsafe(6).replace("-", "").replace("_", "").upper()[:8] def _to_nonnegative_int(value: object) -> int: try: return max(0, int(value or 0)) except (TypeError, ValueError): return 0 CREDIT_QUANTUM = Decimal("0.01") def _credit_decimal(value: object) -> Decimal: try: parsed = Decimal(str(value or 0)) except (InvalidOperation, TypeError, ValueError): return Decimal("0.00") return max(Decimal("0"), parsed).quantize(CREDIT_QUANTUM, rounding=ROUND_HALF_UP) def _credit_number(value: object) -> int | float: parsed = _credit_decimal(value) return int(parsed) if parsed == parsed.to_integral_value() else float(parsed) def _parse_iso_datetime(value: object) -> datetime | None: raw = str(value or "").strip() if not raw: return None try: parsed = datetime.fromisoformat(raw.replace("Z", "+00:00")) except ValueError: return None return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=timezone.utc) def _is_future_datetime(value: object) -> bool: parsed = _parse_iso_datetime(value) return parsed is not None and parsed > datetime.now(timezone.utc) def _normalize_credit_grants(value: object) -> list[dict[str, object]]: source = value if isinstance(value, list) else [] grants: list[dict[str, object]] = [] for raw in source: if not isinstance(raw, dict): continue grant_id = str(raw.get("id") or "").strip() or uuid.uuid4().hex[:12] expires_at = str(raw.get("expires_at") or "").strip() if not expires_at: continue grants.append( { "id": grant_id, "remaining": _credit_number(raw.get("remaining")), "reserved": _credit_number(raw.get("reserved")), "expires_at": expires_at, "created_at": str(raw.get("created_at") or "").strip() or _now_iso(), "source": str(raw.get("source") or "").strip(), "code_id": str(raw.get("code_id") or "").strip(), } ) grants.sort(key=lambda item: str(item.get("expires_at") or "")) return grants def _active_credit_grants(item: dict[str, object]) -> list[dict[str, object]]: return [grant for grant in _normalize_credit_grants(item.get("credit_grants")) if _is_future_datetime(grant.get("expires_at"))] def _expiring_credit_remaining(item: dict[str, object]) -> int | float: return _credit_number(sum((_credit_decimal(grant.get("remaining")) for grant in _active_credit_grants(item)), Decimal("0"))) def _expiring_credit_reserved(item: dict[str, object]) -> int | float: return _credit_number(sum((_credit_decimal(grant.get("reserved")) for grant in _normalize_credit_grants(item.get("credit_grants"))), Decimal("0"))) CHECKIN_CREDIT_SOURCE = "checkin" def _checkin_credit_state(item: dict[str, object]) -> tuple[Decimal, str]: """返回 (该用户还没用掉的签到积分, 最近到期时间)。 签到赠送积分有两种形态,取决于后台「签到积分可用天数」: - 永久形态(可用天数 = 0,默认):直接进基础积分池,由 `checkin_credit_remaining` 账本跟踪; - 限时形态(可用天数 > 0):发放成 `source="checkin"` 的限时券,剩余量记在券上,到期即作废。 两者相加才是「未使用的签到积分」;`expires_at` 取未过期签到券里最早的那张(没有则为空串)。 """ base_balance = _credit_decimal(item.get("credits")) + _credit_decimal(item.get("reserved_credits")) total = min(_credit_decimal(item.get("checkin_credit_remaining")), base_balance) expires_at = "" for grant in _active_credit_grants(item): if str(grant.get("source") or "").strip() != CHECKIN_CREDIT_SOURCE: continue remaining = _credit_decimal(grant.get("remaining")) total += remaining # 剩余为 0 的券(已被扣光但还没清理)不该参与「最近到期时间」,否则会把它当成最近的一笔。 if remaining <= 0: continue candidate = str(grant.get("expires_at") or "").strip() if candidate and (not expires_at or candidate < expires_at): expires_at = candidate return total, expires_at def _checkin_credit_batches(item: dict[str, object]) -> list[dict[str, object]]: """把「未过期的签到限时券」按到期时刻聚合成 [{credits, expires_at}],按到期时间升序。 签到积分逐笔到期(每笔自发放起 N 天后失效),所以这里会天然出现多批;同一时刻到期的 券(例如后台保存设置触发重算后对齐的那批)会合并成一条。 永久形态(可用天数 = 0)没有到期时间,不产生批次 —— 前端据此隐藏到期那几行。 """ grouped: dict[str, Decimal] = {} for grant in _active_credit_grants(item): if str(grant.get("source") or "").strip() != CHECKIN_CREDIT_SOURCE: continue remaining = _credit_decimal(grant.get("remaining")) if remaining <= 0: continue key = str(grant.get("expires_at") or "").strip() grouped[key] = grouped.get(key, Decimal("0")) + remaining return [ {"credits": _credit_number(total), "expires_at": expires_at} for expires_at, total in sorted(grouped.items(), key=lambda pair: pair[0]) ] PACKAGE_UNLIMITED_SENTINEL = 10**9 def _normalize_package_grants(value: object) -> list[dict[str, object]]: source = value if isinstance(value, list) else [] grants: list[dict[str, object]] = [] for raw in source: if not isinstance(raw, dict): continue grant_id = str(raw.get("id") or "").strip() or uuid.uuid4().hex[:12] expires_at = str(raw.get("expires_at") or "").strip() if not expires_at: continue grants.append( { "id": grant_id, "name": str(raw.get("name") or "套餐").strip() or "套餐", "created_at": str(raw.get("created_at") or "").strip() or _now_iso(), "starts_at": str(raw.get("starts_at") or "").strip(), "expires_at": expires_at, "source": str(raw.get("source") or "").strip(), "code_id": str(raw.get("code_id") or "").strip(), "daily_limit": _to_nonnegative_int(raw.get("daily_limit")), "weekly_limit": _to_nonnegative_int(raw.get("weekly_limit")), "monthly_limit": _to_nonnegative_int(raw.get("monthly_limit")), "total_limit": _to_nonnegative_int(raw.get("total_limit")), "used_total": _credit_number(raw.get("used_total")), "reserved_total": _credit_number(raw.get("reserved_total")), "daily_key": str(raw.get("daily_key") or "").strip(), "used_daily": _credit_number(raw.get("used_daily")), "reserved_daily": _credit_number(raw.get("reserved_daily")), "weekly_key": str(raw.get("weekly_key") or "").strip(), "used_weekly": _credit_number(raw.get("used_weekly")), "reserved_weekly": _credit_number(raw.get("reserved_weekly")), "monthly_key": str(raw.get("monthly_key") or "").strip(), "used_monthly": _credit_number(raw.get("used_monthly")), "reserved_monthly": _credit_number(raw.get("reserved_monthly")), } ) grants.sort(key=lambda item: str(item.get("expires_at") or "")) return grants def _normalize_string_list(value: object) -> list[str]: source = value if isinstance(value, list) else [] result: list[str] = [] seen: set[str] = set() for item in source: text = str(item or "").strip() if not text or text in seen: continue seen.add(text) result.append(text) return result def _normalize_third_party_image_wallets(value: object) -> list[dict[str, object]]: source = value if isinstance(value, list) else [] wallets_by_key: dict[str, dict[str, object]] = {} for raw in source: if not isinstance(raw, dict): continue group_id = str(raw.get("group_id") or "").strip() model_id = str(raw.get("model_id") or "").strip() or THIRD_PARTY_IMAGE_GROUP_WALLET_MODEL_ID if not group_id: continue wallet_id = f"{group_id}:{model_id}" wallet = wallets_by_key.get(wallet_id) if wallet is None: wallet = { "id": wallet_id, "group_id": group_id, "model_id": model_id, "remaining": _credit_number(raw.get("remaining")), "reserved": _credit_number(raw.get("reserved")), "used": _credit_number(raw.get("used")), "total_recharged": _credit_number(raw.get("total_recharged")), "updated_at": str(raw.get("updated_at") or "").strip(), } wallets_by_key[wallet_id] = wallet else: wallet["remaining"] = _credit_number(_credit_decimal(wallet.get("remaining")) + _credit_decimal(raw.get("remaining"))) wallet["reserved"] = _credit_number(_credit_decimal(wallet.get("reserved")) + _credit_decimal(raw.get("reserved"))) wallet["used"] = _credit_number(_credit_decimal(wallet.get("used")) + _credit_decimal(raw.get("used"))) wallet["total_recharged"] = _credit_number(_credit_decimal(wallet.get("total_recharged")) + _credit_decimal(raw.get("total_recharged"))) updated_at = str(raw.get("updated_at") or "").strip() if updated_at > str(wallet.get("updated_at") or ""): wallet["updated_at"] = updated_at wallets = list(wallets_by_key.values()) wallets.sort(key=lambda item: (str(item.get("group_id") or ""), str(item.get("model_id") or ""))) return wallets def _normalize_image_model_daily_limits(value: object) -> list[dict[str, object]]: if isinstance(value, dict): source = [ {"model_id": model_id, **entry} if isinstance(entry, dict) else {"model_id": model_id, "daily_limit": entry} for model_id, entry in value.items() ] else: source = value if isinstance(value, list) else [] limits_by_model: dict[str, dict[str, object]] = {} for raw in source: if not isinstance(raw, dict): continue model_id = str(raw.get("model_id") or raw.get("model") or raw.get("id") or "").strip() if not model_id: continue previous = limits_by_model.get(model_id) used = _to_nonnegative_int(raw.get("used")) reserved = _to_nonnegative_int(raw.get("reserved")) if previous is not None and str(previous.get("date") or "") == str(raw.get("date") or raw.get("daily_key") or "").strip(): used += _to_nonnegative_int(previous.get("used")) reserved += _to_nonnegative_int(previous.get("reserved")) limits_by_model[model_id] = { "model_id": model_id, "daily_limit": _to_nonnegative_int(raw.get("daily_limit") if "daily_limit" in raw else raw.get("limit")), "date": str(raw.get("date") or raw.get("daily_key") or "").strip(), "used": used, "reserved": reserved, "updated_at": str(raw.get("updated_at") or "").strip(), } limits = list(limits_by_model.values()) limits.sort(key=lambda item: str(item.get("model_id") or "")) return limits def _reset_image_model_daily_limit(entry: dict[str, object]) -> dict[str, object]: today = _today_key() next_entry = dict(entry) if str(next_entry.get("date") or "").strip() != today and _to_nonnegative_int(next_entry.get("reserved")) <= 0: next_entry["date"] = today next_entry["used"] = 0 next_entry["reserved"] = 0 return next_entry def _image_model_daily_limit_public(entry: dict[str, object]) -> dict[str, object]: normalized = _reset_image_model_daily_limit(entry) daily_limit = _to_nonnegative_int(normalized.get("daily_limit")) used = _to_nonnegative_int(normalized.get("used")) reserved = _to_nonnegative_int(normalized.get("reserved")) return { "model_id": str(normalized.get("model_id") or "").strip(), "daily_limit": daily_limit, "used": used, "reserved": reserved, "remaining": max(0, daily_limit - used - reserved), "date": str(normalized.get("date") or "").strip() or _today_key(), "updated_at": normalized.get("updated_at"), } def _third_party_wallet_key(group_id: str, model_id: str) -> str: group_id = str(group_id or "").strip() model_id = str(model_id or "").strip() return f"{group_id}:{model_id}" if group_id and model_id else "" def _is_matching_third_party_wallet(wallet: dict[str, object], group_id: str, model_id: str) -> bool: group_id = str(group_id or "").strip() model_id = str(model_id or "").strip() wallet_group_id = str(wallet.get("group_id") or "").strip() wallet_model_id = str(wallet.get("model_id") or "").strip() wallet_id = str(wallet.get("id") or "").strip() if wallet_id == _third_party_wallet_key(group_id, model_id): return True return wallet_group_id == group_id and wallet_model_id == model_id def _find_third_party_wallet_for_reserve( wallets: list[dict[str, object]], group_id: str, model_id: str, amount: int | float, ) -> dict[str, object] | None: matches = [wallet for wallet in wallets if _is_matching_third_party_wallet(wallet, group_id, model_id)] if not matches: return None for wallet in matches: if _credit_decimal(wallet.get("remaining")) >= _credit_decimal(amount): return wallet return matches[0] def _find_third_party_wallet_for_settle( wallets: list[dict[str, object]], group_id: str, model_id: str, ) -> dict[str, object] | None: matches = [wallet for wallet in wallets if _is_matching_third_party_wallet(wallet, group_id, model_id)] if not matches: return None for wallet in matches: if _credit_decimal(wallet.get("reserved")) > 0: return wallet return matches[0] def _third_party_wallet_public(wallet: dict[str, object]) -> dict[str, object]: return { "id": wallet.get("id"), "group_id": wallet.get("group_id"), "model_id": wallet.get("model_id"), "remaining": _credit_number(wallet.get("remaining")), "reserved": _credit_number(wallet.get("reserved")), "used": _credit_number(wallet.get("used")), "total_recharged": _credit_number(wallet.get("total_recharged")), "updated_at": wallet.get("updated_at"), } def _reset_package_periods(grant: dict[str, object], now: datetime | None = None) -> dict[str, object]: date_key, week_key, month_key = _period_keys(now) next_grant = dict(grant) if str(next_grant.get("daily_key") or "") != date_key: next_grant["daily_key"] = date_key next_grant["used_daily"] = 0 next_grant["reserved_daily"] = 0 if str(next_grant.get("weekly_key") or "") != week_key: next_grant["weekly_key"] = week_key next_grant["used_weekly"] = 0 next_grant["reserved_weekly"] = 0 if str(next_grant.get("monthly_key") or "") != month_key: next_grant["monthly_key"] = month_key next_grant["used_monthly"] = 0 next_grant["reserved_monthly"] = 0 return next_grant def _package_active(grant: dict[str, object]) -> bool: starts_at = str(grant.get("starts_at") or "").strip() parsed_starts_at = _parse_iso_datetime(starts_at) if starts_at else None return (parsed_starts_at is None or parsed_starts_at <= datetime.now(timezone.utc)) and _is_future_datetime(grant.get("expires_at")) def _package_available(grant: dict[str, object]) -> int | float: if not _package_active(grant): return 0 normalized = _reset_package_periods(grant) candidates: list[Decimal] = [] limit_pairs = [ ("total_limit", "used_total", "reserved_total"), ("daily_limit", "used_daily", "reserved_daily"), ("weekly_limit", "used_weekly", "reserved_weekly"), ("monthly_limit", "used_monthly", "reserved_monthly"), ] for limit_key, used_key, reserved_key in limit_pairs: limit = _to_nonnegative_int(normalized.get(limit_key)) if limit <= 0: continue candidates.append(max(Decimal("0"), Decimal(limit) - _credit_decimal(normalized.get(used_key)) - _credit_decimal(normalized.get(reserved_key)))) if not candidates: return PACKAGE_UNLIMITED_SENTINEL return _credit_number(min(candidates)) def _active_package_grants(item: dict[str, object]) -> list[dict[str, object]]: grants: list[dict[str, object]] = [] for grant in _normalize_package_grants(item.get("package_grants")): grant = _reset_package_periods(grant) if _package_active(grant): grants.append(grant) return grants def _settleable_package_grants(item: dict[str, object]) -> list[dict[str, object]]: return [_reset_package_periods(grant) for grant in _normalize_package_grants(item.get("package_grants"))] def _package_credit_available(item: dict[str, object]) -> int | float: total = Decimal("0") for grant in _active_package_grants(item): total += _credit_decimal(_package_available(grant)) return _credit_number(min(total, Decimal(PACKAGE_UNLIMITED_SENTINEL))) class AuthService: # last_used_at 的写盘节流窗口:同一密钥 60 秒内只回写一次(见 authenticate)。 _TOUCH_INTERVAL_SECONDS = 60.0 # 后台把「待写入的 last_used_at」批量落库的间隔。 _TOUCH_FLUSH_INTERVAL_SECONDS = 30.0 # 缓存未命中时回读整表的节流:未知 token 不能把事件循环拖进数据库。 _RELOAD_MIN_INTERVAL_SECONDS = 2.0 def __init__(self, storage: StorageBackend): self.storage = storage self._lock = RLock() self._items = self._load() # key_id -> 上次写 last_used_at 的时间戳;只在本进程内存里,重启即清空(无害)。 self._last_touch_ts: dict[str, float] = {} # key_id -> 待落库的 last_used_at(ISO 字符串)。 # ⚠️⚠️ 为什么不在请求里直接写库:authenticate 大量跑在**事件循环线程**上 # (async 路由里同步调 require_identity)。以前每次 touch 都要「读整表 → # 改一条 → 写整表」,图片任务线程又在并发结算积分抢同一把存储锁, # 事件循环排到锁后面 ⇒ 15 秒无响应、整站打不开(2026-10-19 事故)。 # 现在只改内存 + 记脏,由后台线程批量合并写一次。 self._pending_touch: dict[str, str] = {} self._last_reload_ts = 0.0 self._reload_requested = False self._flusher_started = False def _ensure_touch_flusher(self) -> None: """惰性启动后台 flush 线程(不在 import 期起,避免测试里凭空多线程)。""" if self._flusher_started: return self._flusher_started = True def loop() -> None: while True: time.sleep(self._TOUCH_FLUSH_INTERVAL_SECONDS) try: self._flush_reload_if_requested() self.flush_pending_touches() except Exception as exc: # pragma: no cover - 只记录,不让线程死掉 print(f"[auth-service] background flush failed: {exc}") thread = Thread(target=loop, name="auth-touch-flusher", daemon=True) thread.start() def _flush_reload_if_requested(self) -> None: """后台兜底:有未命中请求时才回读一次存储(绝不在事件循环里做)。""" with self._lock: if not self._reload_requested: return now_ts = time.time() if now_ts - self._last_reload_ts < self._RELOAD_MIN_INTERVAL_SECONDS: return self._last_reload_ts = now_ts self._reload_requested = False fallback = self._items # 读库放在锁外,避免长时间持锁。 items = self._load(fallback=fallback) with self._lock: self._items = items def flush_pending_touches(self) -> int: """把内存里攒下的 last_used_at 一次性写回存储,返回写入条数。""" with self._lock: pending = dict(self._pending_touch) self._pending_touch.clear() if not pending: return 0 def mutate(items: list[dict[str, object]]) -> tuple[list[dict[str, object]], int]: written = 0 for index, item in enumerate(items): key_id = self._clean(item.get("id")) ts = pending.get(key_id) if not ts or item.get("last_used_at") == ts: continue next_item = dict(item) next_item["last_used_at"] = ts items[index] = next_item written += 1 return items, written try: return int(self._update_items_locked(mutate)) except Exception as exc: # 写失败就把脏数据放回去,下一轮再试(不要让统计字段拖垮请求路径)。 with self._lock: for key_id, ts in pending.items(): self._pending_touch.setdefault(key_id, ts) print(f"[auth-service] flush last_used_at failed: {exc}") return 0 @staticmethod def _clean(value: object) -> str: return str(value or "").strip() @staticmethod def _default_name(role: object) -> str: normalized = str(role or "").strip().lower() if normalized == "admin": return "管理员密钥" if normalized == "normal": return "普通用户" return "内部用户" def _normalize_item(self, raw: object) -> dict[str, object] | None: if not isinstance(raw, dict): return None role = self._clean(raw.get("role")).lower() if role not in {"admin", "user", "normal"}: return None key_hash = self._clean(raw.get("key_hash")) if not key_hash: return None item_id = self._clean(raw.get("id")) or uuid.uuid4().hex[:12] name = self._clean(raw.get("name")) if not name and not self._clean(raw.get("wechat_openid")): # 微信注册用户允许昵称为空(由用户自己在弹窗里填写),不写任何内置默认名。 name = self._default_name(role) created_at = self._clean(raw.get("created_at")) or _now_iso() last_used_at = self._clean(raw.get("last_used_at")) or None return { "id": item_id, "name": name, "role": role, "key_hash": key_hash, "enabled": bool(raw.get("enabled", True)), "account_pool_enabled": bool(raw.get("account_pool_enabled", False)), "image_memory_button_enabled": bool(raw.get("image_memory_button_enabled", False)), "third_party_image_enabled": bool(raw.get("third_party_image_enabled", False)), "third_party_image_group_ids": _normalize_string_list(raw.get("third_party_image_group_ids")), "third_party_image_model_ids": _normalize_string_list(raw.get("third_party_image_model_ids")), "third_party_image_wallets": _normalize_third_party_image_wallets(raw.get("third_party_image_wallets")), "image_model_daily_limits": _normalize_image_model_daily_limits(raw.get("image_model_daily_limits")), "image_model_daily_default_usages": _normalize_image_model_daily_limits(raw.get("image_model_daily_default_usages")), "created_at": created_at, "last_used_at": last_used_at, "credits": _credit_number(raw.get("credits")), # 签到专用账本:签到发放、且尚未被消耗掉的积分余额(见 checkin_admin_summary 注释)。 # 缺字段一律视为 0 —— 该功能上线前已发放的签到积分无法追溯,故不回溯估算。 "checkin_credit_remaining": _credit_number(raw.get("checkin_credit_remaining")), "reserved_credits": _credit_number(raw.get("reserved_credits")), "used_credits": _credit_number(raw.get("used_credits")), "total_recharged": _credit_number(raw.get("total_recharged")), "credit_grants": _normalize_credit_grants(raw.get("credit_grants")), "package_grants": _normalize_package_grants(raw.get("package_grants")), "daily_credits_date": self._clean(raw.get("daily_credits_date")), "daily_credits_used": _credit_number(raw.get("daily_credits_used")), "daily_bonus_credits": _credit_number(raw.get("daily_bonus_credits")), "daily_bonus_model_ids": _normalize_string_list(raw.get("daily_bonus_model_ids")), "daily_reserved_credits": _credit_number(raw.get("daily_reserved_credits")), "api_key_display": self._clean(raw.get("api_key_display")), "wechat_openid": self._clean(raw.get("wechat_openid")), "wechat_nickname": self._clean(raw.get("wechat_nickname")), "wechat_avatar": self._clean(raw.get("wechat_avatar")), "avatar_url": self._clean(raw.get("avatar_url")), "avatar_update_date": self._clean(raw.get("avatar_update_date")), "avatar_update_count": _to_nonnegative_int(raw.get("avatar_update_count")), "wechat_bound_at": self._clean(raw.get("wechat_bound_at")) or None, "invite_code": self._clean(raw.get("invite_code")), "invited_by": self._clean(raw.get("invited_by")), "invite_records": [dict(entry) for entry in (raw.get("invite_records") if isinstance(raw.get("invite_records"), list) else []) if isinstance(entry, dict)], "total_checkin_rewards": _to_nonnegative_int(raw.get("total_checkin_rewards")), "checkin_history": [ { "date": self._clean(entry.get("date")), "reward": _to_nonnegative_int(entry.get("reward")), "mode": "random" if self._clean(entry.get("mode")) == "random" else "checkin", "created_at": self._clean(entry.get("created_at")), } for entry in (raw.get("checkin_history") if isinstance(raw.get("checkin_history"), list) else []) if isinstance(entry, dict) and self._clean(entry.get("date")) ][-400:], } def _load(self, *, fallback: list[dict[str, object]] | None = None) -> list[dict[str, object]]: try: items = self.storage.load_auth_keys() except Exception as exc: print(f"[auth-service] load auth keys failed: {exc}") return [dict(item) for item in (fallback or [])] return self._normalize_items(items, fallback=fallback) def _normalize_items( self, items: object, *, fallback: list[dict[str, object]] | None = None, ) -> list[dict[str, object]]: if not isinstance(items, list): return [dict(item) for item in (fallback or [])] return [normalized for item in items if (normalized := self._normalize_item(item)) is not None] def _save( self, changed_items: list[dict[str, object]] | None = None, deleted_ids: list[str] | None = None, ) -> None: merge_auth_keys = getattr(self.storage, "merge_auth_keys", None) if callable(merge_auth_keys) and (changed_items is not None or deleted_ids): merge_auth_keys( [dict(item) for item in (changed_items or [])], [item_id for item_id in (self._clean(item) for item in (deleted_ids or [])) if item_id], ) return self.storage.save_auth_keys(self._items) def _reload_locked(self) -> None: self._items = self._load(fallback=self._items) def _update_items_locked( self, mutator: Callable[[list[dict[str, object]]], tuple[list[dict[str, object]], ResultT]], ) -> ResultT: update_auth_keys_locked = getattr(self.storage, "update_auth_keys_locked", None) if callable(update_auth_keys_locked): def wrapped(raw_items: list[dict[str, object]]) -> tuple[list[dict[str, object]], ResultT]: # ⚠️ 这里已经持有**存储**锁(update_auth_keys_locked 内部),再去拿鉴权锁时 # 必须保证没有「持鉴权锁等存储锁」的线程 —— 这就是 ABBA 死锁的另一半。 # 现在 authenticate 已改为「先放鉴权锁、再写存储」,加上 list_keys 不再回读 # 存储,环被打断;锁顺序统一为 存储 → 鉴权。 with self._lock: current = self._normalize_items(raw_items, fallback=self._items) self._items = current next_items, result = mutator(current) with self._lock: normalized_next = self._normalize_items(next_items, fallback=current) self._items = normalized_next return normalized_next, result return update_auth_keys_locked(wrapped) with self._lock: self._reload_locked() next_items, result = mutator(self._items) self._items = self._normalize_items(next_items, fallback=self._items) self._save() return result @staticmethod def _daily_state(item: dict[str, object]) -> tuple[int | float, int | float]: if item.get("role") != "normal": return 0, 0 reserved = _credit_number(item.get("daily_reserved_credits")) if AuthService._clean(item.get("daily_credits_date")) != _today_key() and reserved <= 0: return 0, 0 return _credit_number(item.get("daily_credits_used")), reserved @staticmethod def _daily_bonus_credits(item: dict[str, object]) -> int | float: if item.get("role") != "normal": return 0 bonus = _credit_number(item.get("daily_bonus_credits")) if bonus <= 0: return 0 if AuthService._clean(item.get("daily_credits_date")) == _today_key(): return bonus if _credit_decimal(item.get("daily_reserved_credits")) > 0: return bonus return 0 @staticmethod def _daily_bonus_model_ids(item: dict[str, object]) -> list[str]: if AuthService._daily_bonus_credits(item) <= 0: return [] model_ids = _normalize_string_list(item.get("daily_bonus_model_ids")) # Daily gift codes created before model scoping existed are deliberately # kept on the safest default: the built-in account pool only. return model_ids or [DAILY_GIFT_ACCOUNT_POOL_MODEL_ID] @staticmethod def _daily_total(item: dict[str, object]) -> int | float: if item.get("role") != "normal": return 0 return _credit_number(Decimal(config.normal_user_daily_credits) + _credit_decimal(AuthService._daily_bonus_credits(item))) @staticmethod def _daily_remaining(item: dict[str, object], model_id: object = "") -> int | float: used, reserved = AuthService._daily_state(item) normalized_model_id = AuthService._clean(model_id) if not normalized_model_id: return _credit_number(max(Decimal("0"), _credit_decimal(AuthService._daily_total(item)) - _credit_decimal(used) - _credit_decimal(reserved))) base_remaining = _credit_number(max(Decimal("0"), Decimal(config.normal_user_daily_credits) - _credit_decimal(used) - _credit_decimal(reserved))) if normalized_model_id not in AuthService._daily_bonus_model_ids(item): return base_remaining return _credit_number(max(Decimal("0"), _credit_decimal(AuthService._daily_total(item)) - _credit_decimal(used) - _credit_decimal(reserved))) @staticmethod def _reset_daily_if_needed(item: dict[str, object]) -> dict[str, object]: if item.get("role") != "normal": return item today = _today_key() if AuthService._clean(item.get("daily_credits_date")) == today: return item if _credit_decimal(item.get("daily_reserved_credits")) > 0: return item next_item = dict(item) next_item["daily_credits_date"] = today next_item["daily_credits_used"] = 0 next_item["daily_bonus_credits"] = 0 next_item["daily_bonus_model_ids"] = [] next_item["daily_reserved_credits"] = 0 return next_item @staticmethod def _public_item(item: dict[str, object]) -> dict[str, object]: permanent_credits = _credit_number(item.get("credits")) expiring_credits = _expiring_credit_remaining(item) paid_credits = _credit_number(_credit_decimal(permanent_credits) + _credit_decimal(expiring_credits)) package_credits = _package_credit_available(item) daily_used, daily_reserved = AuthService._daily_state(item) daily_total = AuthService._daily_total(item) daily_bonus = AuthService._daily_bonus_credits(item) daily_remaining = AuthService._daily_remaining(item) total_available = _credit_number(_credit_decimal(paid_credits) + _credit_decimal(package_credits) + _credit_decimal(daily_remaining)) if item.get("role") == "normal" else paid_credits checkin_credits, checkin_credits_expires_at = _checkin_credit_state(item) return { "id": item.get("id"), "name": item.get("name"), "role": item.get("role"), "enabled": bool(item.get("enabled", True)), "account_pool_enabled": bool(item.get("account_pool_enabled", False)), "image_memory_button_enabled": bool(item.get("image_memory_button_enabled", False)), "third_party_image_enabled": bool(item.get("third_party_image_enabled", False)), "third_party_image_group_ids": _normalize_string_list(item.get("third_party_image_group_ids")), "third_party_image_model_ids": _normalize_string_list(item.get("third_party_image_model_ids")), "third_party_image_wallets": [ _third_party_wallet_public(wallet) for wallet in _normalize_third_party_image_wallets(item.get("third_party_image_wallets")) if _to_nonnegative_int(wallet.get("remaining")) + _to_nonnegative_int(wallet.get("reserved")) + _to_nonnegative_int(wallet.get("used")) > 0 ], "image_model_daily_limits": [ _image_model_daily_limit_public(limit) for limit in _normalize_image_model_daily_limits(item.get("image_model_daily_limits")) ] if item.get("role") == "user" else [], "image_model_daily_default_usages": [ _image_model_daily_limit_public(limit) for limit in _normalize_image_model_daily_limits(item.get("image_model_daily_default_usages")) ] if item.get("role") == "user" else [], "created_at": item.get("created_at"), "last_used_at": item.get("last_used_at"), "credits": total_available, "paid_credits": paid_credits, "permanent_credits": permanent_credits, "expiring_credits": expiring_credits, "expiring_reserved_credits": _expiring_credit_reserved(item), "package_credits": package_credits if item.get("role") == "normal" else 0, "package_grants": [ { "id": grant.get("id"), "name": grant.get("name"), "remaining": _package_available(grant), "created_at": grant.get("created_at"), "starts_at": grant.get("starts_at"), "expires_at": grant.get("expires_at"), "source": grant.get("source"), "code_id": grant.get("code_id"), "daily_limit": _to_nonnegative_int(grant.get("daily_limit")), "weekly_limit": _to_nonnegative_int(grant.get("weekly_limit")), "monthly_limit": _to_nonnegative_int(grant.get("monthly_limit")), "total_limit": _to_nonnegative_int(grant.get("total_limit")), "used_total": _credit_number(grant.get("used_total")), "reserved_total": _credit_number(grant.get("reserved_total")), "used_daily": _credit_number(grant.get("used_daily")), "reserved_daily": _credit_number(grant.get("reserved_daily")), "used_weekly": _credit_number(grant.get("used_weekly")), "reserved_weekly": _credit_number(grant.get("reserved_weekly")), "used_monthly": _credit_number(grant.get("used_monthly")), "reserved_monthly": _credit_number(grant.get("reserved_monthly")), } for grant in _active_package_grants(item) if _package_available(grant) > 0 ], "credit_grants": [ { "id": grant.get("id"), "remaining": _credit_number(grant.get("remaining")), "reserved": _credit_number(grant.get("reserved")), "expires_at": grant.get("expires_at"), "source": grant.get("source"), } for grant in _active_credit_grants(item) if _credit_decimal(grant.get("remaining")) + _credit_decimal(grant.get("reserved")) > 0 ], "reserved_credits": _credit_number(item.get("reserved_credits")), "used_credits": _credit_number(item.get("used_credits")), "total_recharged": _credit_number(item.get("total_recharged")), "checkin_credit_remaining": _credit_number(item.get("checkin_credit_remaining")), # 「未使用的签到积分」= 永久形态账本 + 未过期的签到券;expires_at 为最近到期时间(空 = 永久/无)。 "checkin_credits": _credit_number(checkin_credits), "checkin_credits_expires_at": checkin_credits_expires_at, # 逐笔到期明细(按到期升序;永久形态为空列表),供前端逐行展示「x 积分 N 天后到期」。 "checkin_credit_batches": _checkin_credit_batches(item), "daily_credits": daily_total if item.get("role") == "normal" else 0, "daily_credits_remaining": daily_remaining, "daily_credits_used": daily_used, "daily_bonus_credits": daily_bonus, "daily_bonus_model_ids": AuthService._daily_bonus_model_ids(item), "daily_reserved_credits": daily_reserved, "daily_credits_date": _today_key() if item.get("role") == "normal" else "", "has_api_key_display": bool(AuthService._clean(item.get("api_key_display"))), "wechat_openid": AuthService._clean(item.get("wechat_openid")), "wechat_nickname": AuthService._clean(item.get("wechat_nickname")), "wechat_avatar": AuthService._clean(item.get("wechat_avatar")), "avatar_url": AuthService._clean(item.get("avatar_url")), "avatar_update_date": AuthService._clean(item.get("avatar_update_date")), "avatar_update_count": _to_nonnegative_int(item.get("avatar_update_count")), "wechat_bound_at": item.get("wechat_bound_at"), "invite_code": AuthService._clean(item.get("invite_code")), "invite_records": [dict(entry) for entry in item.get("invite_records", []) if isinstance(entry, dict)], "total_checkin_rewards": _to_nonnegative_int(item.get("total_checkin_rewards")), } def list_keys(self, role: AuthRole | None = None) -> list[dict[str, object]]: # ⚠️⚠️ 这里**绝对不能**再调 _reload_locked()(那会去拿存储锁)。 # 调用方常常是「已经持有存储事务锁」的商店逻辑(兑换卡密 / 每日礼包在算可用模型), # 一旦这里回头去要存储锁,就和 authenticate(持鉴权锁去写存储)形成 ABBA 死锁, # 而 authenticate 是跑在**事件循环线程**上的 ⇒ 整站彻底无响应、连接被关闭。 # 2026-10-19 事故根因。单进程内所有写入都会同步更新 self._items,缓存即权威。 with self._lock: items = [item for item in self._items if role is None or item.get("role") == role] return [self._public_item(item) for item in items] def checkin_summary(self, user_id: str) -> dict[str, object]: normalized_id = self._clean(user_id) today = _today_key() with self._lock: self._reload_locked() current = next((item for item in self._items if item.get("id") == normalized_id and item.get("role") == "normal"), None) if current is None: raise ValueError("普通用户不存在") if not self._clean(current.get("invite_code")): current["invite_code"] = _invite_code() while any(self._clean(item.get("invite_code")) == current["invite_code"] and item.get("id") != current.get("id") for item in self._items): current["invite_code"] = _invite_code() self._save(changed_items=[current]) history = [entry for entry in current.get("checkin_history", []) if isinstance(entry, dict)] own = [dict(entry) for entry in history if str(entry.get("date") or "")] records = [{"date": str(entry.get("date")), "reward": _to_nonnegative_int(entry.get("reward")), "mode": str(entry.get("mode") or "checkin")} for entry in own] users: list[dict[str, object]] = [] for item in self._items: if item.get("role") != "normal": continue entries = [entry for entry in item.get("checkin_history", []) if isinstance(entry, dict)] users.append({ "id": item.get("id"), "name": item.get("name") or "普通用户", "avatar_url": self._clean(item.get("avatar_url")) or self._clean(item.get("wechat_avatar")), "wechat_avatar": self._clean(item.get("wechat_avatar")), "history": entries, }) daily: list[dict[str, object]] = [] total: list[dict[str, object]] = [] for user in users: entries = user["history"] if isinstance(user.get("history"), list) else [] today_entries = [entry for entry in entries if isinstance(entry, dict) and str(entry.get("date") or "") == today] today_reward = sum(_to_nonnegative_int(entry.get("reward")) for entry in today_entries) today_created_at = min((str(entry.get("created_at") or "") for entry in today_entries), default="") total_reward = sum(_to_nonnegative_int(entry.get("reward")) for entry in entries if isinstance(entry, dict)) days = len({str(entry.get("date")) for entry in entries if isinstance(entry, dict) and str(entry.get("date") or "")}) if today_reward: daily.append({ "name": user["name"], "avatar_url": user.get("avatar_url") or "", "wechat_avatar": user.get("wechat_avatar") or "", "reward": today_reward, "days": days, "created_at": today_created_at, }) if total_reward: total.append({ "name": user["name"], "avatar_url": user.get("avatar_url") or "", "wechat_avatar": user.get("wechat_avatar") or "", "reward": total_reward, "days": days, }) daily.sort(key=lambda item: (str(item.get("created_at") or ""), str(item["name"]))) total.sort(key=lambda item: (-int(item["days"]), str(item["name"]))) invite_records = [] for entry in current.get("invite_records", []): if not isinstance(entry, dict): continue record = dict(entry) invitee_id = self._clean(record.get("invitee_id")) invitee = next((user for user in users if self._clean(user.get("id")) == invitee_id), None) if invitee is not None: record["invitee_name"] = invitee.get("name") or record.get("invitee_name") invite_records.append(record) total_rewards = sum(_to_nonnegative_int(entry.get("reward")) for entry in own) invite_rewards = sum(_to_nonnegative_int(entry.get("reward")) for entry in invite_records) return { "today": today, "checked_in": any(str(entry.get("date")) == today for entry in own), "records": records, "daily": daily[:100], "total": total[:100], "total_rewards": total_rewards, "invite": { "code": self._clean(current.get("invite_code")), "successful": len(invite_records), "rewards": invite_rewards, "records": invite_records, }, } def checkin_admin_summary(self) -> dict[str, object]: today = _today_key() with self._lock: self._reload_locked() users = [item for item in self._items if item.get("role") == "normal"] today_count = 0 total_rewards = 0 unused_credits = Decimal("0.00") for item in users: entries = [entry for entry in item.get("checkin_history", []) if isinstance(entry, dict)] if any(str(entry.get("date") or "") == today for entry in entries): today_count += 1 issued = sum(_to_nonnegative_int(entry.get("reward")) for entry in entries) total_rewards += issued # 「未使用」= 签到专用账本(永久形态)+ 未过期的签到券(限时形态,见 _checkin_credit_state)。 # 账本只统计本功能上线后的签到发放 —— 上线前已发放的签到积分无法追溯,不回溯估算; # 因此上线初期这个数会低于 total_rewards,属预期。 checkin_unused, _ = _checkin_credit_state(item) unused_credits += checkin_unused return { "normal_users": len(users), "today_checked_in": today_count, "total_rewards": total_rewards, "unused_credits": _credit_number(unused_credits), } def perform_checkin(self, user_id: str, *, mode: str = "checkin", reward: int = 1) -> dict[str, object]: normalized_id = self._clean(user_id) today = _today_key() normalized_mode = "random" if mode == "random" else "checkin" normalized_reward = max(1, min(1000, int(reward or 1))) # 后台「签到积分可用天数」:0 = 永久(进基础积分池);>0 则发放成带 expires_at 的限时券。 valid_days = max(0, min(3650, int(config.checkin_credit_valid_days or 0))) expires_at = (datetime.now(timezone.utc) + timedelta(days=valid_days)).isoformat() if valid_days > 0 else "" def mutate(items: list[dict[str, object]]) -> tuple[list[dict[str, object]], dict[str, object]]: for index, item in enumerate(items): if item.get("id") != normalized_id or item.get("role") != "normal": continue history = [dict(entry) for entry in item.get("checkin_history", []) if isinstance(entry, dict)] if any(str(entry.get("date") or "") == today for entry in history): raise ValueError("今天已经签到过了") entry = {"date": today, "reward": normalized_reward, "mode": normalized_mode, "created_at": _now_iso()} if expires_at: entry["expires_at"] = expires_at next_item = dict(item) next_item["checkin_history"] = [*history[-399:], entry] next_items = list(items) next_items[index] = next_item return next_items, {"date": today, "reward": normalized_reward, "mode": normalized_mode, "expires_at": expires_at} raise ValueError("普通用户不存在") result = self._update_items_locked(mutate) self.adjust_credits(normalized_id, normalized_reward, source="checkin", expires_at=expires_at or None) return result def reconcile_checkin_credit_expiry(self, valid_days: int) -> dict[str, object]: """按后台「签到积分可用天数」重算所有普通用户**已发放**的签到积分形态。 由后台保存该配置时调用(且仅在值真的变化时),让新设置立即对历史积分生效, 不必等用户下一次签到。语义: - valid_days > 0:把永久账本余额(`checkin_credit_remaining`)从基础积分池扣出, 转成 `expires_at = now + N 天` 的限时券(这部分原来没有到期时间,给足 N 天宽限); 已有的签到限时券**保留各自原有节奏**,到期时间 = `created_at + N 天`(缺 created_at 才退回 now + N 天)。 若重算后已经过了到期时刻(把 N 调小到早于某些批次的发放日),这批直接作废。 - valid_days == 0:把所有签到限时券的剩余量转回基础积分池 + 永久账本,券移除。 只处理 `source="checkin"` 的积分;卡密 / 后台加积分 / 邀请 / 套餐券一律不动。 `reserved > 0`(正在预扣)的券跳过,避免打断进行中的生成任务。 """ days = max(0, min(3650, int(valid_days or 0))) now = datetime.now(timezone.utc) expires_at = (now + timedelta(days=days)).isoformat() if days > 0 else "" converted = Decimal("0") skipped = 0 expired = 0 touched = 0 def mutate(items: list[dict[str, object]]) -> tuple[list[dict[str, object]], None]: nonlocal converted, skipped, expired, touched next_items: list[dict[str, object]] = [] for item in items: if item.get("role") != "normal": next_items.append(item) continue grants = _normalize_credit_grants(item.get("credit_grants")) checkin_grants = [grant for grant in grants if str(grant.get("source") or "").strip() == CHECKIN_CREDIT_SOURCE] other_grants = [grant for grant in grants if str(grant.get("source") or "").strip() != CHECKIN_CREDIT_SOURCE] credits = _credit_decimal(item.get("credits")) ledger = _credit_decimal(item.get("checkin_credit_remaining")) next_credits = credits next_ledger = ledger next_checkin_grants: list[dict[str, object]] = [] for grant in checkin_grants: if _credit_decimal(grant.get("reserved")) > 0: skipped += 1 next_checkin_grants.append(grant) continue remaining = _credit_decimal(grant.get("remaining")) if remaining <= 0: continue if days > 0: # 各批次保留自己的发放节奏:到期 = 发放时刻 + N 天。 issued = _parse_iso_datetime(grant.get("created_at")) target = (issued + timedelta(days=days)) if issued is not None else (now + timedelta(days=days)) if target <= now: # 按「自发放起 N 天」的口径,这批已经过期 —— 作废(不是扣费,是自然到期)。 expired += 1 continue next_checkin_grants.append({**grant, "expires_at": target.isoformat()}) else: next_credits += remaining next_ledger += remaining converted += remaining if days > 0: movable = min(ledger, credits) if movable > 0: next_credits = credits - movable next_ledger = ledger - movable next_checkin_grants.append( { "id": uuid.uuid4().hex[:12], "remaining": _credit_number(movable), "reserved": 0, "expires_at": expires_at, "created_at": _now_iso(), "source": CHECKIN_CREDIT_SOURCE, "code_id": "", } ) converted += movable next_grants = [*other_grants, *next_checkin_grants] next_grants.sort(key=lambda grant: str(grant.get("expires_at") or "")) if next_credits == credits and next_ledger == ledger and next_grants == grants: next_items.append(item) continue next_item = dict(item) next_item["credits"] = _credit_number(next_credits) next_item["checkin_credit_remaining"] = _credit_number(next_ledger) next_item["credit_grants"] = next_grants next_items.append(next_item) touched += 1 return next_items, None self._update_items_locked(mutate) result = { "valid_days": days, "users": touched, "converted": _credit_number(converted), "expired_grants": expired, "skipped_grants": skipped, } print(f"[checkin] 签到积分有效期重算:{result}") return result def get_key(self, key_id: str, *, role: AuthRole | None = None) -> dict[str, object] | None: normalized_id = self._clean(key_id) if not normalized_id: return None with self._lock: self._reload_locked() for item in self._items: if item.get("id") == normalized_id and (role is None or item.get("role") == role): return self._public_item(item) return None def _has_key_hash_locked(self, key_hash: str, *, exclude_id: str = "") -> bool: for item in self._items: item_id = self._clean(item.get("id")) if exclude_id and item_id == exclude_id: continue stored_hash = self._clean(item.get("key_hash")) if stored_hash and hmac.compare_digest(stored_hash, key_hash): return True return False def _build_key_hash_locked(self, raw_key: str, *, exclude_id: str = "") -> str: candidate = self._clean(raw_key) if not candidate: raise ValueError("请输入新的专用密钥") admin_key = self._clean(config.auth_key) if admin_key and hmac.compare_digest(candidate, admin_key): raise ValueError("这个密钥和管理员密钥冲突,请换一个") key_hash = _hash_key(candidate) if self._has_key_hash_locked(key_hash, exclude_id=exclude_id): raise ValueError("这个专用密钥已经存在,请换一个") return key_hash def _has_name_locked(self, name: str, *, role: AuthRole | None = None, exclude_id: str = "") -> bool: candidate = self._clean(name) if not candidate: return False for item in self._items: item_id = self._clean(item.get("id")) if exclude_id and item_id == exclude_id: continue if role is not None and item.get("role") != role: continue if self._clean(item.get("name")) == candidate: return True return False def _build_default_name_locked(self, role: AuthRole, *, exclude_id: str = "") -> str: base_name = self._default_name(role) if not self._has_name_locked(base_name, role=role, exclude_id=exclude_id): return base_name suffix = 2 while True: candidate = f"{base_name} {suffix}" if not self._has_name_locked(candidate, role=role, exclude_id=exclude_id): return candidate suffix += 1 def _build_name_locked(self, name: str, *, role: AuthRole, exclude_id: str = "") -> str: candidate = self._clean(name) if not candidate: return self._build_default_name_locked(role, exclude_id=exclude_id) if len(candidate) > 32: raise ValueError("昵称最多 32 个字符") if self._has_name_locked(candidate, role=role, exclude_id=exclude_id): raise ValueError("这个名称已经在使用中了,请换一个") return candidate def _build_unique_name_locked(self, name: str, *, role: AuthRole, exclude_id: str = "") -> str: candidate = self._clean(name) if not candidate: return self._build_default_name_locked(role, exclude_id=exclude_id) if not self._has_name_locked(candidate, role=role, exclude_id=exclude_id): return candidate suffix = 2 while True: next_candidate = f"{candidate} {suffix}" if not self._has_name_locked(next_candidate, role=role, exclude_id=exclude_id): return next_candidate suffix += 1 def create_key(self, *, role: AuthRole, name: str = "") -> tuple[dict[str, object], str]: def mutate(items: list[dict[str, object]]) -> tuple[list[dict[str, object]], tuple[dict[str, object], str]]: normalized_name = self._build_name_locked(name, role=role) while True: raw_key = f"sk-{secrets.token_urlsafe(24)}" try: key_hash = self._build_key_hash_locked(raw_key) break except ValueError: continue item = { "id": uuid.uuid4().hex[:12], "name": normalized_name, "role": role, "key_hash": key_hash, "enabled": True, "account_pool_enabled": False, "image_memory_button_enabled": False, "third_party_image_enabled": False, "third_party_image_group_ids": [], "third_party_image_model_ids": [], "third_party_image_wallets": [], "image_model_daily_limits": [], "image_model_daily_default_usages": [], "created_at": _now_iso(), "last_used_at": None, "credits": config.normal_user_default_credits if role == "normal" else 0, "reserved_credits": 0, "used_credits": 0, "total_recharged": 0, "credit_grants": [], "package_grants": [], "daily_credits_date": _today_key() if role == "normal" else "", "daily_credits_used": 0, "daily_bonus_credits": 0, "daily_reserved_credits": 0, "api_key_display": raw_key if role in {"normal", "user"} else "", "wechat_openid": "", "wechat_nickname": "", "wechat_avatar": "", "wechat_bound_at": None, "checkin_history": [], "invite_code": "", "invited_by": "", "invite_records": [], "total_checkin_rewards": 0, } if role == "normal": candidate = _invite_code() while any(self._clean(existing.get("invite_code")) == candidate for existing in items): candidate = _invite_code() item["invite_code"] = candidate return [*items, item], (self._public_item(item), raw_key) return self._update_items_locked(mutate) def create_invited_normal_user(self, *, name: str, invite_code: str) -> tuple[dict[str, object], str]: normalized_name = self._clean(name) or "普通用户" normalized_invite_code = self._clean(invite_code).upper() if not normalized_invite_code: raise ValueError("请输入邀请码") invite_reward = config.checkin_invite_reward def mutate(items: list[dict[str, object]]) -> tuple[list[dict[str, object]], tuple[dict[str, object], str]]: inviter = next((item for item in items if item.get("role") == "normal" and self._clean(item.get("invite_code")).upper() == normalized_invite_code), None) if inviter is None: raise ValueError("邀请码无效") while True: raw_key = f"sk-{secrets.token_urlsafe(24)}" key_hash = self._build_key_hash_locked(raw_key) break candidate = _invite_code() while any(self._clean(existing.get("invite_code")) == candidate for existing in items): candidate = _invite_code() item = { "id": uuid.uuid4().hex[:12], "name": self._build_unique_name_locked(normalized_name, role="normal"), "role": "normal", "key_hash": key_hash, "enabled": True, "account_pool_enabled": False, "image_memory_button_enabled": False, "third_party_image_enabled": False, "third_party_image_group_ids": [], "third_party_image_model_ids": [], "third_party_image_wallets": [], "image_model_daily_limits": [], "image_model_daily_default_usages": [], "created_at": _now_iso(), "last_used_at": None, "credits": config.normal_user_default_credits, "reserved_credits": 0, "used_credits": 0, "total_recharged": 0, "credit_grants": [], "package_grants": [], "daily_credits_date": _today_key(), "daily_credits_used": 0, "daily_bonus_credits": 0, "daily_reserved_credits": 0, "api_key_display": raw_key, "wechat_openid": "", "wechat_nickname": "", "wechat_avatar": "", "wechat_bound_at": None, "checkin_history": [], "invite_code": candidate, "invited_by": str(inviter.get("id") or ""), "invite_records": [], "total_checkin_rewards": 0, } record = {"invitee_id": item["id"], "invitee_name": item["name"], "registered_at": _now_iso(), "reward": invite_reward} inviter_next = dict(inviter) inviter_next["invite_records"] = [*([dict(entry) for entry in inviter.get("invite_records", []) if isinstance(entry, dict)])[-99:], record] next_items = list(items) next_items[next((i for i, current in enumerate(items) if current.get("id") == inviter.get("id")), 0)] = inviter_next next_items.append(item) return next_items, (self._public_item(item), raw_key) result = self._update_items_locked(mutate) inviter_id = "" created = result[0] with self._lock: self._reload_locked() inviter_id = str(next((item.get("id") for item in self._items if self._clean(item.get("invite_code")).upper() == normalized_invite_code), "") or "") if inviter_id: self.adjust_credits(inviter_id, invite_reward, source="invite") self.adjust_credits(str(created.get("id") or ""), invite_reward, source="invite") return result def attach_invite(self, user_id: str, invite_code: str) -> None: normalized_id = self._clean(user_id) normalized_code = self._clean(invite_code).upper() if not normalized_id or not normalized_code: return inviter_id = "" already = False with self._lock: self._reload_locked() target = next((item for item in self._items if item.get("id") == normalized_id and item.get("role") == "normal"), None) inviter = next((item for item in self._items if item.get("role") == "normal" and self._clean(item.get("invite_code")).upper() == normalized_code), None) if target is None or inviter is None or target.get("id") == inviter.get("id") or self._clean(target.get("invited_by")): return inviter_id = str(inviter.get("id") or "") invite_reward = config.checkin_invite_reward record = {"invitee_id": target.get("id"), "invitee_name": target.get("name"), "registered_at": _now_iso(), "reward": invite_reward} next_items = list(self._items) target_next = dict(target); target_next["invited_by"] = inviter_id inviter_next = dict(inviter); inviter_next["invite_records"] = [*([dict(entry) for entry in inviter.get("invite_records", []) if isinstance(entry, dict)])[-99:], record] next_items[next(i for i, item in enumerate(self._items) if item.get("id") == target.get("id"))] = target_next next_items[next(i for i, item in enumerate(self._items) if item.get("id") == inviter.get("id"))] = inviter_next self._items = self._normalize_items(next_items, fallback=self._items) self._save(changed_items=[target_next, inviter_next]) self.adjust_credits(inviter_id, config.checkin_invite_reward, source="invite") self.adjust_credits(normalized_id, config.checkin_invite_reward, source="invite") def get_or_create_wechat_normal_user( self, *, openid: str, nickname: str = "", avatar: str = "", allow_create: bool = True, ) -> tuple[dict[str, object], str, bool]: normalized_openid = self._clean(openid) if not normalized_openid: raise ValueError("wechat openid is required") normalized_nickname = self._clean(nickname) normalized_avatar = self._clean(avatar) now = _now_iso() def mutate(items: list[dict[str, object]]) -> tuple[list[dict[str, object]], tuple[dict[str, object], str, bool]]: for index, item in enumerate(items): if item.get("role") != "normal" or self._clean(item.get("wechat_openid")) != normalized_openid: continue next_item = dict(item) next_item["wechat_nickname"] = normalized_nickname next_item["wechat_avatar"] = normalized_avatar next_item["wechat_bound_at"] = self._clean(next_item.get("wechat_bound_at")) or now raw_key = self._clean(next_item.get("api_key_display")) if not raw_key: while True: raw_key = f"sk-{secrets.token_urlsafe(24)}" try: next_item["key_hash"] = self._build_key_hash_locked(raw_key, exclude_id=self._clean(next_item.get("id"))) break except ValueError: continue next_item["api_key_display"] = raw_key next_items = list(items) next_items[index] = next_item return next_items, (self._public_item(next_item), raw_key, False) if not allow_create: raise ValueError("当前暂不开放新用户注册,请使用已绑定过的账号登录。") # 新注册的微信用户不预置「微信用户 xxxx」这类内置昵称,留空由用户自己设置。 normalized_name = "" while True: raw_key = f"sk-{secrets.token_urlsafe(24)}" try: key_hash = self._build_key_hash_locked(raw_key) break except ValueError: continue item = { "id": uuid.uuid4().hex[:12], "name": normalized_name, "role": "normal", "key_hash": key_hash, "enabled": True, "account_pool_enabled": False, "image_memory_button_enabled": False, "third_party_image_enabled": False, "third_party_image_group_ids": [], "third_party_image_model_ids": [], "third_party_image_wallets": [], "created_at": now, "last_used_at": None, "credits": config.normal_user_default_credits, "reserved_credits": 0, "used_credits": 0, "total_recharged": 0, "credit_grants": [], "package_grants": [], "daily_credits_date": _today_key(), "daily_credits_used": 0, "daily_bonus_credits": 0, "daily_reserved_credits": 0, "api_key_display": raw_key, "wechat_openid": normalized_openid, "wechat_nickname": normalized_nickname, "wechat_avatar": normalized_avatar, "wechat_bound_at": now, "checkin_history": [], "invite_code": _invite_code(), "invited_by": "", "invite_records": [], "total_checkin_rewards": 0, } return [*items, item], (self._public_item(item), raw_key, True) return self._update_items_locked(mutate) def get_profile(self, key_id: str) -> dict[str, object] | None: normalized_id = self._clean(key_id) if not normalized_id: return None item = self.get_key(normalized_id) if item is None: return None return { "id": item.get("id"), "name": item.get("name"), "role": item.get("role"), "created_at": item.get("created_at"), "account_pool_enabled": bool(item.get("account_pool_enabled")), "third_party_image_enabled": bool(item.get("third_party_image_enabled")), "third_party_image_group_ids": _normalize_string_list(item.get("third_party_image_group_ids")), "third_party_image_model_ids": _normalize_string_list(item.get("third_party_image_model_ids")), "third_party_image_wallets": item.get("third_party_image_wallets") if isinstance(item.get("third_party_image_wallets"), list) else [], "wechat_openid": self._clean(item.get("wechat_openid")), "wechat_avatar": self._clean(item.get("wechat_avatar")), "avatar_url": self._clean(item.get("avatar_url")), "avatar_updates_remaining": max( 0, 3 - ( _to_nonnegative_int(item.get("avatar_update_count")) if self._clean(item.get("avatar_update_date")) == _today_key() else 0 ), ), "wechat_bound_at": item.get("wechat_bound_at"), "invite_code": self._clean(item.get("invite_code")), "invite_records": [dict(entry) for entry in item.get("invite_records", []) if isinstance(entry, dict)], "total_checkin_rewards": _to_nonnegative_int(item.get("total_checkin_rewards")), } def update_self_name(self, key_id: str, name: str) -> dict[str, object]: item = self.update_key(key_id, {"name": name}, role="normal") if item is None: raise ValueError("普通用户不存在") return item def update_self_avatar(self, key_id: str, avatar_url: str) -> dict[str, object]: normalized_id = self._clean(key_id) normalized_url = self._clean(avatar_url) if not normalized_id or not normalized_url: raise ValueError("头像地址无效") today = _today_key() def mutate(items: list[dict[str, object]]) -> tuple[list[dict[str, object]], dict[str, object]]: for index, item in enumerate(items): if item.get("id") != normalized_id or item.get("role") != "normal": continue update_count = ( _to_nonnegative_int(item.get("avatar_update_count")) if self._clean(item.get("avatar_update_date")) == today else 0 ) if update_count >= 3: raise ValueError("头像每天最多修改 3 次,请明天再试") next_item = dict(item) next_item["avatar_url"] = normalized_url next_item["avatar_update_date"] = today next_item["avatar_update_count"] = update_count + 1 next_items = list(items) next_items[index] = next_item return next_items, self._public_item(next_item) raise ValueError("普通用户不存在") return self._update_items_locked(mutate) def get_api_key_display(self, key_id: str) -> str: normalized_id = self._clean(key_id) if not normalized_id: return "" with self._lock: self._reload_locked() for item in self._items: if item.get("id") == normalized_id and item.get("role") in {"normal", "user"}: return self._clean(item.get("api_key_display")) return "" def update_key( self, key_id: str, updates: dict[str, object], *, role: AuthRole | None = None, ) -> dict[str, object] | None: normalized_id = self._clean(key_id) if not normalized_id: return None def mutate(items: list[dict[str, object]]) -> tuple[list[dict[str, object]], dict[str, object] | None]: for index, item in enumerate(items): if item.get("id") != normalized_id: continue if role is not None and item.get("role") != role: return items, None next_item = dict(item) next_role = str(next_item.get("role") or "user") if next_role not in {"admin", "user", "normal"}: next_role = "user" if "name" in updates and updates.get("name") is not None: next_item["name"] = self._build_name_locked( str(updates.get("name") or ""), role=next_role, # type: ignore[arg-type] exclude_id=normalized_id, ) if "enabled" in updates and updates.get("enabled") is not None: next_item["enabled"] = bool(updates.get("enabled")) if "account_pool_enabled" in updates and updates.get("account_pool_enabled") is not None: next_item["account_pool_enabled"] = bool(updates.get("account_pool_enabled")) if "image_memory_button_enabled" in updates and updates.get("image_memory_button_enabled") is not None: next_item["image_memory_button_enabled"] = bool(updates.get("image_memory_button_enabled")) if "third_party_image_enabled" in updates and updates.get("third_party_image_enabled") is not None: next_item["third_party_image_enabled"] = bool(updates.get("third_party_image_enabled")) if "third_party_image_group_ids" in updates and updates.get("third_party_image_group_ids") is not None: next_item["third_party_image_group_ids"] = _normalize_string_list(updates.get("third_party_image_group_ids")) if "third_party_image_model_ids" in updates and updates.get("third_party_image_model_ids") is not None: next_item["third_party_image_model_ids"] = _normalize_string_list(updates.get("third_party_image_model_ids")) if "image_model_daily_limits" in updates and updates.get("image_model_daily_limits") is not None: next_item["image_model_daily_limits"] = ( _normalize_image_model_daily_limits(updates.get("image_model_daily_limits")) if next_role == "user" else [] ) if "key" in updates and updates.get("key") is not None: raw_key = str(updates.get("key") or "") next_item["key_hash"] = self._build_key_hash_locked(raw_key, exclude_id=normalized_id) if next_role in {"normal", "user"}: next_item["api_key_display"] = self._clean(raw_key) next_items = list(items) next_items[index] = next_item return next_items, self._public_item(next_item) return items, None return self._update_items_locked(mutate) def reserve_internal_image_model_daily_limit( self, key_id: str, *, model_id: str, amount: int, ) -> dict[str, object]: normalized_id = self._clean(key_id) normalized_model_id = self._clean(model_id) normalized_amount = max(0, int(amount or 0)) if not normalized_id or not normalized_model_id or normalized_amount <= 0: return {"_internal_image_model_daily_limit_reservation": {"amount": 0}} def mutate(items: list[dict[str, object]]) -> tuple[list[dict[str, object]], dict[str, object]]: for index, item in enumerate(items): if item.get("id") != normalized_id or item.get("role") != "user": continue limits = _normalize_image_model_daily_limits(item.get("image_model_daily_limits")) limit = next((entry for entry in limits if self._clean(entry.get("model_id")) == normalized_model_id), None) source_field = "image_model_daily_limits" reservation_scope = "user" limit_scope = "per_user" if limit is None: default_limit = config.get_internal_image_model_daily_limit(normalized_model_id) if default_limit is None: return items, {"_internal_image_model_daily_limit_reservation": {"amount": 0}} limit_scope = str(default_limit.get("limit_scope") or "per_user").strip().lower() if limit_scope == "total": total_result = config.reserve_internal_image_model_total_daily_limit(normalized_model_id, normalized_amount) total_reservation = total_result.get("_internal_image_model_daily_limit_reservation") if isinstance(total_result, dict) else {} return items, { "_internal_image_model_daily_limit_reservation": total_reservation if isinstance(total_reservation, dict) else {"amount": 0} } source_field = "image_model_daily_default_usages" reservation_scope = "default_user" limits = _normalize_image_model_daily_limits(item.get(source_field)) limit = next((entry for entry in limits if self._clean(entry.get("model_id")) == normalized_model_id), None) if limit is None: limit = { "model_id": normalized_model_id, "daily_limit": _to_nonnegative_int(default_limit.get("daily_limit")), "date": _today_key(), "used": 0, "reserved": 0, "updated_at": "", } limits.append(limit) else: limit["daily_limit"] = _to_nonnegative_int(default_limit.get("daily_limit")) limit = _reset_image_model_daily_limit(limit) daily_limit = _to_nonnegative_int(limit.get("daily_limit")) used = _to_nonnegative_int(limit.get("used")) reserved = _to_nonnegative_int(limit.get("reserved")) remaining = max(0, daily_limit - used - reserved) if normalized_amount > remaining: if remaining <= 0: raise ValueError(f"{normalized_model_id} 今日使用数量已达上限") raise ValueError(f"{normalized_model_id} 今日剩余 {remaining} 张,不足以生成 {normalized_amount} 张") limit["reserved"] = reserved + normalized_amount limit["date"] = str(limit.get("date") or "").strip() or _today_key() limit["updated_at"] = _now_iso() next_limits = [ limit if self._clean(entry.get("model_id")) == normalized_model_id else entry for entry in limits ] next_item = dict(item) next_item[source_field] = next_limits next_items = list(items) next_items[index] = next_item result = self._public_item(next_item) result["_internal_image_model_daily_limit_reservation"] = { "model_id": normalized_model_id, "amount": normalized_amount, "scope": reservation_scope, "limit_scope": limit_scope, } return next_items, result return items, {"_internal_image_model_daily_limit_reservation": {"amount": 0}} return self._update_items_locked(mutate) def consume_internal_image_model_daily_limit( self, key_id: str, *, model_id: str, amount: int, scope: str = "", ) -> dict[str, object] | None: normalized_id = self._clean(key_id) normalized_model_id = self._clean(model_id) normalized_amount = max(0, int(amount or 0)) if not normalized_id or not normalized_model_id or normalized_amount <= 0: return None normalized_scope = self._clean(scope) if normalized_scope == "default_total": return config.consume_internal_image_model_total_daily_limit(normalized_model_id, normalized_amount) source_field = "image_model_daily_default_usages" if normalized_scope == "default_user" else "image_model_daily_limits" def mutate(items: list[dict[str, object]]) -> tuple[list[dict[str, object]], dict[str, object] | None]: for index, item in enumerate(items): if item.get("id") != normalized_id or item.get("role") != "user": continue limits = _normalize_image_model_daily_limits(item.get(source_field)) changed = False for limit in limits: if self._clean(limit.get("model_id")) != normalized_model_id: continue limit = _reset_image_model_daily_limit(limit) reserved = _to_nonnegative_int(limit.get("reserved")) consumed = min(reserved, normalized_amount) limit["reserved"] = reserved - consumed limit["used"] = _to_nonnegative_int(limit.get("used")) + consumed limit["updated_at"] = _now_iso() changed = True next_limits = [ limit if self._clean(entry.get("model_id")) == normalized_model_id else entry for entry in limits ] next_item = dict(item) next_item[source_field] = next_limits next_items = list(items) next_items[index] = next_item return next_items, self._public_item(next_item) if not changed: return items, None return items, None return self._update_items_locked(mutate) def refund_internal_image_model_daily_limit( self, key_id: str, *, model_id: str, amount: int, scope: str = "", ) -> dict[str, object] | None: normalized_id = self._clean(key_id) normalized_model_id = self._clean(model_id) normalized_amount = max(0, int(amount or 0)) if not normalized_id or not normalized_model_id or normalized_amount <= 0: return None normalized_scope = self._clean(scope) if normalized_scope == "default_total": return config.refund_internal_image_model_total_daily_limit(normalized_model_id, normalized_amount) source_field = "image_model_daily_default_usages" if normalized_scope == "default_user" else "image_model_daily_limits" def mutate(items: list[dict[str, object]]) -> tuple[list[dict[str, object]], dict[str, object] | None]: for index, item in enumerate(items): if item.get("id") != normalized_id or item.get("role") != "user": continue limits = _normalize_image_model_daily_limits(item.get(source_field)) for limit in limits: if self._clean(limit.get("model_id")) != normalized_model_id: continue limit = _reset_image_model_daily_limit(limit) reserved = _to_nonnegative_int(limit.get("reserved")) refunded = min(reserved, normalized_amount) limit["reserved"] = reserved - refunded limit["updated_at"] = _now_iso() next_limits = [ limit if self._clean(entry.get("model_id")) == normalized_model_id else entry for entry in limits ] next_item = dict(item) next_item[source_field] = next_limits next_items = list(items) next_items[index] = next_item return next_items, self._public_item(next_item) return items, None return self._update_items_locked(mutate) def delete_key(self, key_id: str, *, role: AuthRole | None = None) -> bool: normalized_id = self._clean(key_id) if not normalized_id: return False # Database-backed stores can delete one row directly. Avoid loading, # normalizing, and rewriting every user record: a single malformed # legacy row should not prevent an unrelated user from being deleted. merge_auth_keys = getattr(self.storage, "merge_auth_keys", None) if callable(merge_auth_keys): with self._lock: items = self._load(fallback=self._items) target = next( ( item for item in items if item.get("id") == normalized_id and (role is None or item.get("role") == role) ), None, ) if target is None: self._items = items return False merge_auth_keys([], [normalized_id]) self._items = [item for item in items if item.get("id") != normalized_id] return True def mutate(items: list[dict[str, object]]) -> tuple[list[dict[str, object]], bool]: next_items = [ item for item in items if not (item.get("id") == normalized_id and (role is None or item.get("role") == role)) ] return next_items, len(next_items) < len(items) return self._update_items_locked(mutate) def batch_mutate_keys( self, key_ids: list[str], *, action: Literal["enable", "disable", "delete"], role: AuthRole, ) -> dict[str, object]: """Enable, disable, or delete multiple users in one storage transaction.""" normalized_ids = list( dict.fromkeys( item_id for item_id in (self._clean(key_id) for key_id in key_ids) if item_id ) ) if not normalized_ids: raise ValueError("请至少选择一个用户") if action not in {"enable", "disable", "delete"}: raise ValueError("不支持的批量操作") if role not in {"user", "normal"}: raise ValueError("不支持的用户类型") selected_ids = set(normalized_ids) with self._lock: items = self._load(fallback=self._items) matched_ids = { str(item.get("id") or "") for item in items if item.get("id") in selected_ids and item.get("role") == role } if action == "delete": next_items = [item for item in items if str(item.get("id") or "") not in matched_ids] if matched_ids: self._items = next_items self._save(changed_items=[], deleted_ids=list(matched_ids)) else: self._items = items changed_count = len(matched_ids) else: enabled = action == "enable" changed_items: list[dict[str, object]] = [] next_items: list[dict[str, object]] = [] for item in items: item_id = str(item.get("id") or "") if item_id not in matched_ids or bool(item.get("enabled")) == enabled: next_items.append(item) continue next_item = dict(item) next_item["enabled"] = enabled next_items.append(next_item) changed_items.append(next_item) self._items = next_items if changed_items: self._save(changed_items=changed_items, deleted_ids=[]) changed_count = len(changed_items) return { "action": action, "requested": len(normalized_ids), "matched": len(matched_ids), "changed": changed_count, } def batch_update_image_permissions( self, updates: list[dict[str, object]], *, role: AuthRole, ) -> dict[str, object]: """Update image routing permissions for many users in one storage transaction.""" if role not in {"user", "normal"}: raise ValueError("不支持的用户类型") normalized_updates = { self._clean(update.get("id")): update for update in updates if isinstance(update, dict) and self._clean(update.get("id")) } if not normalized_updates: raise ValueError("请至少选择一个用户") with self._lock: items = self._load(fallback=self._items) changed_items: list[dict[str, object]] = [] next_items: list[dict[str, object]] = [] matched = 0 for item in items: item_id = self._clean(item.get("id")) update = normalized_updates.get(item_id) if update is None or item.get("role") != role: next_items.append(item) continue matched += 1 next_item = dict(item) if "third_party_image_enabled" in update: next_item["third_party_image_enabled"] = bool(update.get("third_party_image_enabled")) if "third_party_image_group_ids" in update: next_item["third_party_image_group_ids"] = _normalize_string_list(update.get("third_party_image_group_ids")) if "third_party_image_model_ids" in update: next_item["third_party_image_model_ids"] = _normalize_string_list(update.get("third_party_image_model_ids")) next_items.append(next_item) if next_item != item: changed_items.append(next_item) self._items = next_items if changed_items: self._save(changed_items=changed_items, deleted_ids=[]) return { "requested": len(normalized_updates), "matched": matched, "changed": len(changed_items), } def credit_summary(self, identity: dict[str, object]) -> dict[str, object]: role = self._clean(identity.get("role")).lower() if role != "normal": return { "role": role, "metered": False, "credits": None, "paid_credits": None, "reserved_credits": 0, "used_credits": None, "total_recharged": None, "permanent_credits": None, "expiring_credits": 0, "expiring_reserved_credits": 0, "credit_grants": [], "package_credits": 0, "package_grants": [], "daily_credits": 0, "daily_credits_remaining": 0, "daily_credits_used": None, "daily_bonus_credits": 0, "daily_reserved_credits": 0, "daily_credits_date": "", "checkin_credits": 0, "checkin_credits_expires_at": "", "checkin_credit_batches": [], "third_party_image_enabled": False, "third_party_image_wallets": [], } item = self.get_key(self._clean(identity.get("id")), role="normal") if item is None: return { "role": role, "metered": True, "credits": 0, "paid_credits": 0, "reserved_credits": 0, "used_credits": 0, "total_recharged": 0, "permanent_credits": 0, "expiring_credits": 0, "expiring_reserved_credits": 0, "credit_grants": [], "package_credits": 0, "package_grants": [], "daily_credits": config.normal_user_daily_credits, "daily_credits_remaining": 0, "daily_credits_used": 0, "daily_bonus_credits": 0, "daily_reserved_credits": 0, "daily_credits_date": _today_key(), "checkin_credits": 0, "checkin_credits_expires_at": "", "checkin_credit_batches": [], "third_party_image_enabled": False, "third_party_image_wallets": [], } return self.credit_summary_from_public_item(item) def credit_summary_from_public_item(self, item: dict[str, object]) -> dict[str, object]: return { "role": "normal", "metered": True, "credits": _credit_number(item.get("credits")), "paid_credits": _credit_number(item.get("paid_credits")), "reserved_credits": _credit_number(item.get("reserved_credits")), "used_credits": _credit_number(item.get("used_credits")), "total_recharged": _credit_number(item.get("total_recharged")), "permanent_credits": _credit_number(item.get("permanent_credits")), "expiring_credits": _credit_number(item.get("expiring_credits")), "expiring_reserved_credits": _credit_number(item.get("expiring_reserved_credits")), "credit_grants": item.get("credit_grants") if isinstance(item.get("credit_grants"), list) else [], "package_credits": _credit_number(item.get("package_credits")), "package_grants": item.get("package_grants") if isinstance(item.get("package_grants"), list) else [], "daily_credits": _credit_number(item.get("daily_credits")), "daily_credits_remaining": _credit_number(item.get("daily_credits_remaining")), "daily_credits_used": _credit_number(item.get("daily_credits_used")), "daily_bonus_credits": _credit_number(item.get("daily_bonus_credits")), "daily_reserved_credits": _credit_number(item.get("daily_reserved_credits")), "daily_credits_date": self._clean(item.get("daily_credits_date")), "checkin_credits": _credit_number(item.get("checkin_credits")), "checkin_credits_expires_at": self._clean(item.get("checkin_credits_expires_at")), "checkin_credit_batches": item.get("checkin_credit_batches") if isinstance(item.get("checkin_credit_batches"), list) else [], "third_party_image_enabled": bool(item.get("third_party_image_enabled")), "third_party_image_wallets": item.get("third_party_image_wallets") if isinstance(item.get("third_party_image_wallets"), list) else [], } def _adjust_credits_in_items( self, items: list[dict[str, object]], key_id: str, delta: int, *, note: str = "", expires_at: str | None = None, source: str = "", code_id: str = "", ) -> tuple[list[dict[str, object]], dict[str, object]]: amount = int(delta) for index, item in enumerate(items): if item.get("id") != key_id or item.get("role") != "normal": continue next_item = dict(item) credits = _credit_decimal(item.get("credits")) if amount > 0 and _is_future_datetime(expires_at): grants = _normalize_credit_grants(item.get("credit_grants")) grants.append( { "id": uuid.uuid4().hex[:12], "remaining": amount, "reserved": 0, "expires_at": str(expires_at or "").strip(), "created_at": _now_iso(), "source": self._clean(source or note), "code_id": self._clean(code_id), } ) grants.sort(key=lambda grant: str(grant.get("expires_at") or "")) next_item["credit_grants"] = grants next_item["credits"] = _credit_number(credits) else: next_credits = credits + Decimal(amount) if next_credits < 0: raise ValueError("用户剩余次数不足,不能扣成负数") next_item["credits"] = _credit_number(next_credits) # 签到账本:只有落进基础积分池的变动才记账。 # 发放(source=checkin)累加;手动扣分按「签到积分最先被消耗」扣减。 checkin_remaining = _credit_decimal(item.get("checkin_credit_remaining")) if amount > 0: if self._clean(source) == "checkin": checkin_remaining += Decimal(amount) elif amount < 0: checkin_remaining = max(Decimal("0"), checkin_remaining + Decimal(amount)) next_item["checkin_credit_remaining"] = _credit_number(checkin_remaining) if amount > 0: next_item["total_recharged"] = _credit_number(_credit_decimal(item.get("total_recharged")) + Decimal(amount)) next_items = list(items) next_items[index] = next_item return next_items, self._public_item(next_item) raise ValueError("普通用户不存在") def adjust_credits(self, key_id: str, delta: int, *, note: str = "", expires_at: str | None = None, source: str = "", code_id: str = "") -> dict[str, object]: normalized_id = self._clean(key_id) if not normalized_id: raise ValueError("用户不存在") def mutate(items: list[dict[str, object]]) -> tuple[list[dict[str, object]], dict[str, object]]: return self._adjust_credits_in_items( items, normalized_id, int(delta), note=note, expires_at=expires_at, source=source, code_id=code_id, ) return self._update_items_locked(mutate) def _add_third_party_image_wallet_in_items( self, items: list[dict[str, object]], key_id: str, *, group_id: str, model_id: str, amount: int, auto_enable: bool = True, ) -> tuple[list[dict[str, object]], dict[str, object]]: normalized_id = self._clean(key_id) normalized_group_id = self._clean(group_id) normalized_model_id = self._clean(model_id) normalized_amount = max(1, _to_nonnegative_int(amount)) if not normalized_group_id: raise ValueError("image API group is required") if not normalized_model_id: raise ValueError("image API model is required") for index, item in enumerate(items): if item.get("id") != normalized_id or item.get("role") != "normal": continue wallets = _normalize_third_party_image_wallets(item.get("third_party_image_wallets")) wallet_key = f"{normalized_group_id}:{normalized_model_id}" wallet = next((entry for entry in wallets if self._clean(entry.get("id")) == wallet_key), None) if wallet is None: wallet = { "id": wallet_key, "group_id": normalized_group_id, "model_id": normalized_model_id, "remaining": 0, "reserved": 0, "used": 0, "total_recharged": 0, "updated_at": "", } wallets.append(wallet) wallet["remaining"] = _credit_number(_credit_decimal(wallet.get("remaining")) + _credit_decimal(normalized_amount)) wallet["total_recharged"] = _credit_number(_credit_decimal(wallet.get("total_recharged")) + _credit_decimal(normalized_amount)) wallet["updated_at"] = _now_iso() next_item = dict(item) next_item["third_party_image_wallets"] = wallets if auto_enable: next_item["third_party_image_enabled"] = True if normalized_model_id == THIRD_PARTY_IMAGE_GROUP_WALLET_MODEL_ID: group_ids = set(_normalize_string_list(next_item.get("third_party_image_group_ids"))) group_ids.add(normalized_group_id) next_item["third_party_image_group_ids"] = sorted(group_ids) next_item["third_party_image_model_ids"] = [] else: model_ids = set(_normalize_string_list(next_item.get("third_party_image_model_ids"))) model_ids.add(f"{normalized_group_id}:{normalized_model_id}") next_item["third_party_image_model_ids"] = sorted(model_ids) next_items = list(items) next_items[index] = next_item return next_items, self._public_item(next_item) raise ValueError("normal user not found") def adjust_third_party_image_wallet( self, key_id: str, *, group_id: str, model_id: str, delta: int, source: str = "", code_id: str = "", auto_enable: bool = True, ) -> dict[str, object]: normalized_id = self._clean(key_id) normalized_group_id = self._clean(group_id) normalized_model_id = self._clean(model_id) amount = int(delta) if not normalized_id: raise ValueError("user not found") if not normalized_group_id: raise ValueError("image API group is required") if not normalized_model_id: raise ValueError("image API model is required") def mutate(items: list[dict[str, object]]) -> tuple[list[dict[str, object]], dict[str, object]]: for index, item in enumerate(items): if item.get("id") != normalized_id or item.get("role") != "normal": continue wallets = _normalize_third_party_image_wallets(item.get("third_party_image_wallets")) wallet_key = f"{normalized_group_id}:{normalized_model_id}" wallet = next((entry for entry in wallets if self._clean(entry.get("id")) == wallet_key), None) if wallet is None: wallet = { "id": wallet_key, "group_id": normalized_group_id, "model_id": normalized_model_id, "remaining": 0, "reserved": 0, "used": 0, "total_recharged": 0, "updated_at": "", } wallets.append(wallet) next_remaining = _credit_decimal(wallet.get("remaining")) + Decimal(amount) if next_remaining < Decimal("0"): raise ValueError("4K image balance is not enough") wallet["remaining"] = _credit_number(next_remaining) wallet["updated_at"] = _now_iso() if amount > 0: wallet["total_recharged"] = _credit_number(_credit_decimal(wallet.get("total_recharged")) + Decimal(amount)) next_item = dict(item) next_item["third_party_image_wallets"] = wallets if auto_enable: next_item["third_party_image_enabled"] = True if normalized_model_id == THIRD_PARTY_IMAGE_GROUP_WALLET_MODEL_ID: group_ids = set(_normalize_string_list(next_item.get("third_party_image_group_ids"))) group_ids.add(normalized_group_id) next_item["third_party_image_group_ids"] = sorted(group_ids) next_item["third_party_image_model_ids"] = [] else: model_ids = set(_normalize_string_list(next_item.get("third_party_image_model_ids"))) model_ids.add(f"{normalized_group_id}:{normalized_model_id}") next_item["third_party_image_model_ids"] = sorted(model_ids) next_items = list(items) next_items[index] = next_item return next_items, self._public_item(next_item) raise ValueError("normal user not found") return self._update_items_locked(mutate) def reserve_third_party_image_wallet( self, key_id: str, *, group_id: str, model_id: str, amount: int | float, ) -> dict[str, object]: normalized_id = self._clean(key_id) normalized_group_id = self._clean(group_id) normalized_model_id = self._clean(model_id) normalized_amount = _credit_decimal(amount) if normalized_amount <= 0: raise ValueError("image API model balance amount must be positive") def mutate(items: list[dict[str, object]]) -> tuple[list[dict[str, object]], dict[str, object]]: for index, item in enumerate(items): if item.get("id") != normalized_id or item.get("role") != "normal": continue wallets = _normalize_third_party_image_wallets(item.get("third_party_image_wallets")) wallet = _find_third_party_wallet_for_reserve(wallets, normalized_group_id, normalized_model_id, normalized_amount) if wallet is None: raise ValueError("image API model balance is not enough") remaining = _credit_decimal(wallet.get("remaining")) if remaining < normalized_amount: raise ValueError("image API model balance is not enough") wallet["remaining"] = _credit_number(remaining - normalized_amount) wallet["reserved"] = _credit_number(_credit_decimal(wallet.get("reserved")) + normalized_amount) wallet["updated_at"] = _now_iso() next_item = dict(item) next_item["third_party_image_wallets"] = wallets next_items = list(items) next_items[index] = next_item result = self._public_item(next_item) result["_third_party_image_reservation"] = { "group_id": normalized_group_id, "model_id": normalized_model_id, "amount": _credit_number(normalized_amount), } return next_items, result raise ValueError("normal user not found") return self._update_items_locked(mutate) def consume_third_party_image_wallet( self, key_id: str, *, group_id: str, model_id: str, amount: int | float, ) -> dict[str, object] | None: normalized_id = self._clean(key_id) normalized_group_id = self._clean(group_id) normalized_model_id = self._clean(model_id) normalized_amount = _credit_decimal(amount) if normalized_amount <= 0: return None def mutate(items: list[dict[str, object]]) -> tuple[list[dict[str, object]], dict[str, object] | None]: for index, item in enumerate(items): if item.get("id") != normalized_id or item.get("role") != "normal": continue wallets = _normalize_third_party_image_wallets(item.get("third_party_image_wallets")) wallet = _find_third_party_wallet_for_settle(wallets, normalized_group_id, normalized_model_id) if wallet is not None: reserved = _credit_decimal(wallet.get("reserved")) consumed = min(reserved, normalized_amount) wallet["reserved"] = _credit_number(reserved - consumed) wallet["used"] = _credit_number(_credit_decimal(wallet.get("used")) + consumed) wallet["updated_at"] = _now_iso() next_item = dict(item) next_item["third_party_image_wallets"] = wallets next_items = list(items) next_items[index] = next_item return next_items, self._public_item(next_item) return items, None return items, None return self._update_items_locked(mutate) def refund_third_party_image_wallet( self, key_id: str, *, group_id: str, model_id: str, amount: int | float, ) -> dict[str, object] | None: normalized_id = self._clean(key_id) normalized_group_id = self._clean(group_id) normalized_model_id = self._clean(model_id) normalized_amount = _credit_decimal(amount) if normalized_amount <= 0: return None def mutate(items: list[dict[str, object]]) -> tuple[list[dict[str, object]], dict[str, object] | None]: for index, item in enumerate(items): if item.get("id") != normalized_id or item.get("role") != "normal": continue wallets = _normalize_third_party_image_wallets(item.get("third_party_image_wallets")) wallet = _find_third_party_wallet_for_settle(wallets, normalized_group_id, normalized_model_id) if wallet is not None: reserved = _credit_decimal(wallet.get("reserved")) refunded = min(reserved, normalized_amount) wallet["reserved"] = _credit_number(reserved - refunded) wallet["remaining"] = _credit_number(_credit_decimal(wallet.get("remaining")) + refunded) wallet["updated_at"] = _now_iso() next_item = dict(item) next_item["third_party_image_wallets"] = wallets next_items = list(items) next_items[index] = next_item return next_items, self._public_item(next_item) return items, None return items, None return self._update_items_locked(mutate) def _add_daily_credits_in_items( self, items: list[dict[str, object]], key_id: str, amount: int, *, source: str = "", code_id: str = "", model_ids: list[str] | None = None, ) -> tuple[list[dict[str, object]], dict[str, object]]: normalized_amount = max(1, _to_nonnegative_int(amount)) today = _today_key() for index, item in enumerate(items): if item.get("id") != key_id or item.get("role") != "normal": continue normalized_item = self._reset_daily_if_needed(dict(item)) next_item = dict(normalized_item) next_item["daily_credits_date"] = today next_item["daily_bonus_credits"] = _to_nonnegative_int(normalized_item.get("daily_bonus_credits")) + normalized_amount normalized_model_ids = _normalize_string_list(model_ids) or [DAILY_GIFT_ACCOUNT_POOL_MODEL_ID] merged_model_ids = set(self._daily_bonus_model_ids(normalized_item)) merged_model_ids.update(normalized_model_ids) next_item["daily_bonus_model_ids"] = sorted(merged_model_ids) third_party_model_ids = { model_id for model_id in normalized_model_ids if model_id != DAILY_GIFT_ACCOUNT_POOL_MODEL_ID and ":" in model_id } if third_party_model_ids: next_item["third_party_image_enabled"] = True permitted_model_ids = set(_normalize_string_list(next_item.get("third_party_image_model_ids"))) permitted_model_ids.update(third_party_model_ids) next_item["third_party_image_model_ids"] = sorted(permitted_model_ids) next_items = list(items) next_items[index] = next_item return next_items, self._public_item(next_item) raise ValueError("普通用户不存在") def add_daily_credits( self, key_id: str, amount: int, *, source: str = "", code_id: str = "", model_ids: list[str] | None = None, ) -> dict[str, object]: normalized_id = self._clean(key_id) if not normalized_id: raise ValueError("用户不存在") def mutate(items: list[dict[str, object]]) -> tuple[list[dict[str, object]], dict[str, object]]: return self._add_daily_credits_in_items( items, normalized_id, int(amount), source=source, code_id=code_id, model_ids=model_ids, ) return self._update_items_locked(mutate) def _add_package_grant_in_items( self, items: list[dict[str, object]], key_id: str, package_plan: dict[str, object], *, source: str = "", code_id: str = "", ) -> tuple[list[dict[str, object]], dict[str, object]]: duration_days = max(1, _to_nonnegative_int(package_plan.get("duration_days")) or 1) now = datetime.now(timezone.utc) grant = { "id": uuid.uuid4().hex[:12], "name": self._clean(package_plan.get("name")) or "套餐", "created_at": now.isoformat(), "starts_at": now.isoformat(), "expires_at": (now + timedelta(days=duration_days)).isoformat(), "source": self._clean(source), "code_id": self._clean(code_id), "daily_limit": _to_nonnegative_int(package_plan.get("daily_limit")), "weekly_limit": _to_nonnegative_int(package_plan.get("weekly_limit")), "monthly_limit": _to_nonnegative_int(package_plan.get("monthly_limit")), "total_limit": _to_nonnegative_int(package_plan.get("total_limit")), "used_total": 0, "reserved_total": 0, "used_daily": 0, "reserved_daily": 0, "used_weekly": 0, "reserved_weekly": 0, "used_monthly": 0, "reserved_monthly": 0, } if max( _to_nonnegative_int(grant.get("daily_limit")), _to_nonnegative_int(grant.get("weekly_limit")), _to_nonnegative_int(grant.get("monthly_limit")), _to_nonnegative_int(grant.get("total_limit")), ) <= 0: raise ValueError("套餐卡密至少需要设置一个日/周/月/总限额") grant = _reset_package_periods(grant) for index, item in enumerate(items): if item.get("id") != key_id or item.get("role") != "normal": continue normalized_item = self._reset_daily_if_needed(dict(item)) grants = _active_package_grants(normalized_item) grants.append(grant) next_item = dict(normalized_item) next_item["package_grants"] = grants next_items = list(items) next_items[index] = next_item return next_items, self._public_item(next_item) raise ValueError("普通用户不存在") def add_package_grant(self, key_id: str, package_plan: dict[str, object], *, source: str = "", code_id: str = "") -> dict[str, object]: normalized_id = self._clean(key_id) if not normalized_id: raise ValueError("用户不存在") def mutate(items: list[dict[str, object]]) -> tuple[list[dict[str, object]], dict[str, object]]: return self._add_package_grant_in_items( items, normalized_id, package_plan, source=source, code_id=code_id, ) return self._update_items_locked(mutate) def reserve_credits(self, key_id: str, amount: int | float, *, model_id: str = "") -> dict[str, object]: """按固定优先级从各积分池预扣,并在返回值里带上 `_credit_reservation` 明细。 扣费顺序(原则:先到期的先用): 1. 每日免费额度 `daily_credits_remaining` —— 当天有效,次日清零,最先扣 2. 套餐券 `package_grants` —— 受日 / 周 / 月限额约束 3. 限时券 `credit_grants` —— 带 `expires_at`,先到期的先扣 4. 基础积分池 `credits` —— 永久有效,最后扣 签到赠送的积分(`perform_checkin` 发放、无 `expires_at`)落在第 4 项基础积分池, 即在整个链条上排最后;但在**该池内部**它最先被消耗(赠送优先消费, 见 `consume_reserved_credits` 里的签到账本)。各池的消耗归属见 `test/test_checkin_credit_ledger.py`。 """ amount_decimal = _credit_decimal(amount) if amount_decimal <= 0: result = self.credit_summary({"id": key_id, "role": "normal"}) result["_credit_reservation"] = {"daily": 0, "packages": [], "paid": 0, "permanent_paid": 0, "grants": []} return result normalized_id = self._clean(key_id) def mutate(items: list[dict[str, object]]) -> tuple[list[dict[str, object]], dict[str, object]]: for index, item in enumerate(items): if item.get("id") != normalized_id or item.get("role") != "normal": continue normalized_item = self._reset_daily_if_needed(dict(item)) credits = _credit_decimal(normalized_item.get("credits")) grants = _normalize_credit_grants(normalized_item.get("credit_grants")) package_grants = _active_package_grants(normalized_item) expiring_available = sum((_credit_decimal(grant.get("remaining")) for grant in grants if _is_future_datetime(grant.get("expires_at"))), Decimal("0")) daily_available = _credit_decimal(self._daily_remaining(normalized_item, model_id)) daily_to_reserve = min(amount_decimal, daily_available) remaining_to_reserve = amount_decimal - daily_to_reserve package_reservations: list[dict[str, object]] = [] for grant in package_grants: if remaining_to_reserve <= 0: break take = min(_credit_decimal(_package_available(grant)), remaining_to_reserve) if take <= 0: continue if _to_nonnegative_int(grant.get("total_limit")) > 0: grant["reserved_total"] = _credit_number(_credit_decimal(grant.get("reserved_total")) + take) if _to_nonnegative_int(grant.get("daily_limit")) > 0: grant["reserved_daily"] = _credit_number(_credit_decimal(grant.get("reserved_daily")) + take) if _to_nonnegative_int(grant.get("weekly_limit")) > 0: grant["reserved_weekly"] = _credit_number(_credit_decimal(grant.get("reserved_weekly")) + take) if _to_nonnegative_int(grant.get("monthly_limit")) > 0: grant["reserved_monthly"] = _credit_number(_credit_decimal(grant.get("reserved_monthly")) + take) package_reservations.append({"id": grant.get("id"), "amount": _credit_number(take)}) remaining_to_reserve -= take paid_to_reserve = remaining_to_reserve if credits + expiring_available < paid_to_reserve: raise ValueError("剩余积分不足,请先充值") next_item = dict(normalized_item) remaining_paid_to_reserve = paid_to_reserve grant_reservations: list[dict[str, object]] = [] for grant in grants: if remaining_paid_to_reserve <= 0 or not _is_future_datetime(grant.get("expires_at")): continue grant_remaining = _credit_decimal(grant.get("remaining")) take = min(grant_remaining, remaining_paid_to_reserve) if take <= 0: continue grant["remaining"] = _credit_number(grant_remaining - take) grant["reserved"] = _credit_number(_credit_decimal(grant.get("reserved")) + take) grant_reservations.append({"id": grant.get("id"), "amount": _credit_number(take)}) remaining_paid_to_reserve -= take permanent_to_reserve = remaining_paid_to_reserve next_item["credits"] = _credit_number(credits - permanent_to_reserve) next_item["reserved_credits"] = _credit_number(_credit_decimal(normalized_item.get("reserved_credits")) + permanent_to_reserve) next_item["daily_reserved_credits"] = _credit_number(_credit_decimal(normalized_item.get("daily_reserved_credits")) + daily_to_reserve) next_item["credit_grants"] = grants next_item["package_grants"] = package_grants result = self._public_item(next_item) result["_credit_reservation"] = { "daily": _credit_number(daily_to_reserve), "packages": package_reservations, "paid": _credit_number(paid_to_reserve), "permanent_paid": _credit_number(permanent_to_reserve), "grants": grant_reservations, } next_items = list(items) next_items[index] = next_item return next_items, result raise ValueError("普通用户不存在") return self._update_items_locked(mutate) def consume_reserved_credits( self, key_id: str, amount: int | float, *, daily_amount: int | float | None = None, package_reservations: list[dict[str, object]] | None = None, paid_amount: int | float | None = None, grant_reservations: list[dict[str, object]] | None = None, ) -> dict[str, object] | None: amount_decimal = _credit_decimal(amount) if amount_decimal <= 0: return self.get_key(key_id, role="normal") normalized_id = self._clean(key_id) def mutate(items: list[dict[str, object]]) -> tuple[list[dict[str, object]], dict[str, object] | None]: for index, item in enumerate(items): if item.get("id") != normalized_id or item.get("role") != "normal": continue normalized_item = self._reset_daily_if_needed(dict(item)) daily_reserved = _credit_decimal(normalized_item.get("daily_reserved_credits")) paid_reserved = _credit_decimal(normalized_item.get("reserved_credits")) grants = _normalize_credit_grants(normalized_item.get("credit_grants")) package_grants = _settleable_package_grants(normalized_item) package_consumed = Decimal("0") for reservation in package_reservations or []: if not isinstance(reservation, dict): continue package_id = self._clean(reservation.get("id")) requested = _credit_decimal(reservation.get("amount")) if not package_id or requested <= 0: continue for grant in package_grants: if self._clean(grant.get("id")) != package_id: continue consumed_from_package = Decimal("0") if _to_nonnegative_int(grant.get("total_limit")) > 0: take = min(requested, _credit_decimal(grant.get("reserved_total"))) grant["reserved_total"] = _credit_number(_credit_decimal(grant.get("reserved_total")) - take) grant["used_total"] = _credit_number(_credit_decimal(grant.get("used_total")) + take) consumed_from_package = max(consumed_from_package, take) if _to_nonnegative_int(grant.get("daily_limit")) > 0: take = min(requested, _credit_decimal(grant.get("reserved_daily"))) grant["reserved_daily"] = _credit_number(_credit_decimal(grant.get("reserved_daily")) - take) grant["used_daily"] = _credit_number(_credit_decimal(grant.get("used_daily")) + take) consumed_from_package = max(consumed_from_package, take) if _to_nonnegative_int(grant.get("weekly_limit")) > 0: take = min(requested, _credit_decimal(grant.get("reserved_weekly"))) grant["reserved_weekly"] = _credit_number(_credit_decimal(grant.get("reserved_weekly")) - take) grant["used_weekly"] = _credit_number(_credit_decimal(grant.get("used_weekly")) + take) consumed_from_package = max(consumed_from_package, take) if _to_nonnegative_int(grant.get("monthly_limit")) > 0: take = min(requested, _credit_decimal(grant.get("reserved_monthly"))) grant["reserved_monthly"] = _credit_number(_credit_decimal(grant.get("reserved_monthly")) - take) grant["used_monthly"] = _credit_number(_credit_decimal(grant.get("used_monthly")) + take) consumed_from_package = max(consumed_from_package, take) package_consumed += consumed_from_package break grant_consumed = Decimal("0") for reservation in grant_reservations or []: if not isinstance(reservation, dict): continue grant_id = self._clean(reservation.get("id")) requested = _credit_decimal(reservation.get("amount")) if not grant_id or requested <= 0: continue for grant in grants: if self._clean(grant.get("id")) != grant_id: continue consumed_from_grant = min(requested, _credit_decimal(grant.get("reserved"))) grant["reserved"] = _credit_number(_credit_decimal(grant.get("reserved")) - consumed_from_grant) grant_consumed += consumed_from_grant break if daily_amount is None and paid_amount is None: daily_consumed = min(amount_decimal, daily_reserved) paid_consumed = min(amount_decimal - daily_consumed, paid_reserved) else: daily_consumed = min(_credit_decimal(daily_amount), daily_reserved) paid_consumed = min(_credit_decimal(paid_amount), paid_reserved) consumed = daily_consumed + paid_consumed next_item = dict(normalized_item) # 签到账本按「签到积分最先被消耗」扣减;只有从**基础积分池**扣掉的部分算数 # (每日免费额度 / 限时券 / 套餐券各自独立,不动签到账本)。 checkin_remaining = _credit_decimal(normalized_item.get("checkin_credit_remaining")) if checkin_remaining > 0 and paid_consumed > 0: checkin_remaining = max(Decimal("0"), checkin_remaining - paid_consumed) next_item["checkin_credit_remaining"] = _credit_number(checkin_remaining) next_item["daily_reserved_credits"] = _credit_number(daily_reserved - daily_consumed) next_item["daily_credits_used"] = _credit_number(_credit_decimal(normalized_item.get("daily_credits_used")) + daily_consumed) next_item["reserved_credits"] = _credit_number(paid_reserved - paid_consumed) next_item["credit_grants"] = grants next_item["package_grants"] = package_grants next_item["used_credits"] = _credit_number(_credit_decimal(normalized_item.get("used_credits")) + consumed + grant_consumed + package_consumed) next_items = list(items) next_items[index] = next_item return next_items, self._public_item(next_item) return items, None return self._update_items_locked(mutate) def refund_reserved_credits( self, key_id: str, amount: int | float, *, daily_amount: int | float | None = None, paid_amount: int | float | None = None, package_reservations: list[dict[str, object]] | None = None, grant_reservations: list[dict[str, object]] | None = None, ) -> dict[str, object] | None: amount_decimal = _credit_decimal(amount) if amount_decimal <= 0: return self.get_key(key_id, role="normal") normalized_id = self._clean(key_id) def mutate(items: list[dict[str, object]]) -> tuple[list[dict[str, object]], dict[str, object] | None]: for index, item in enumerate(items): if item.get("id") != normalized_id or item.get("role") != "normal": continue normalized_item = self._reset_daily_if_needed(dict(item)) daily_reserved = _credit_decimal(normalized_item.get("daily_reserved_credits")) paid_reserved = _credit_decimal(normalized_item.get("reserved_credits")) grants = _normalize_credit_grants(normalized_item.get("credit_grants")) package_grants = _settleable_package_grants(normalized_item) for reservation in package_reservations or []: if not isinstance(reservation, dict): continue package_id = self._clean(reservation.get("id")) requested = _credit_decimal(reservation.get("amount")) if not package_id or requested <= 0: continue for grant in package_grants: if self._clean(grant.get("id")) != package_id: continue if _to_nonnegative_int(grant.get("total_limit")) > 0: take = min(requested, _credit_decimal(grant.get("reserved_total"))) grant["reserved_total"] = _credit_number(_credit_decimal(grant.get("reserved_total")) - take) if _to_nonnegative_int(grant.get("daily_limit")) > 0: take = min(requested, _credit_decimal(grant.get("reserved_daily"))) grant["reserved_daily"] = _credit_number(_credit_decimal(grant.get("reserved_daily")) - take) if _to_nonnegative_int(grant.get("weekly_limit")) > 0: take = min(requested, _credit_decimal(grant.get("reserved_weekly"))) grant["reserved_weekly"] = _credit_number(_credit_decimal(grant.get("reserved_weekly")) - take) if _to_nonnegative_int(grant.get("monthly_limit")) > 0: take = min(requested, _credit_decimal(grant.get("reserved_monthly"))) grant["reserved_monthly"] = _credit_number(_credit_decimal(grant.get("reserved_monthly")) - take) break for reservation in grant_reservations or []: if not isinstance(reservation, dict): continue grant_id = self._clean(reservation.get("id")) requested = _credit_decimal(reservation.get("amount")) if not grant_id or requested <= 0: continue for grant in grants: if self._clean(grant.get("id")) != grant_id: continue refunded_from_grant = min(requested, _credit_decimal(grant.get("reserved"))) grant["reserved"] = _credit_number(_credit_decimal(grant.get("reserved")) - refunded_from_grant) grant["remaining"] = _credit_number(_credit_decimal(grant.get("remaining")) + refunded_from_grant) break if daily_amount is None and paid_amount is None: daily_refunded = min(amount_decimal, daily_reserved) paid_refunded = min(amount_decimal - daily_refunded, paid_reserved) else: daily_refunded = min(_credit_decimal(daily_amount), daily_reserved) paid_refunded = min(_credit_decimal(paid_amount), paid_reserved) next_item = dict(normalized_item) next_item["daily_reserved_credits"] = _credit_number(daily_reserved - daily_refunded) next_item["reserved_credits"] = _credit_number(paid_reserved - paid_refunded) next_item["credits"] = _credit_number(_credit_decimal(normalized_item.get("credits")) + paid_refunded) next_item["credit_grants"] = grants next_item["package_grants"] = package_grants next_items = list(items) next_items[index] = next_item return next_items, self._public_item(next_item) return items, None return self._update_items_locked(mutate) def authenticate(self, raw_key: str, *, touch: bool = True) -> dict[str, object] | None: candidate = self._clean(raw_key) if not candidate: return None candidate_hash = _hash_key(candidate) def find_match(items: list[dict[str, object]]) -> tuple[int, dict[str, object]] | None: for index, item in enumerate(items): if not bool(item.get("enabled", True)): continue stored_hash = self._clean(item.get("key_hash")) if stored_hash and hmac.compare_digest(stored_hash, candidate_hash): return index, item return None # ⚠️⚠️ 这一段跑在**事件循环线程**上(async 路由里同步调 require_identity), # 所以里面**绝不允许出现任何存储 IO**。锁内只做内存查找 + 内存更新。 missed = False public_item: dict[str, object] | None = None with self._lock: matched = find_match(self._items) if matched is None: # ⚠️ 缓存未命中**也不回读存储**:读库同样要排队等图片任务线程的写锁, # 而这里跑在事件循环上。单副本部署下内存快照就是权威 # (创建/修改/删除密钥都会同步更新 self._items), # 回读只对「外部进程直接改库」有意义 —— 交给后台线程兜底。 self._reload_requested = True missed = True else: index, matched_item = matched if touch: # last_used_at 节流:同一密钥 60 秒内只回写一次。 throttle_key = self._clean(matched_item.get("id")) or candidate_hash now_ts = time.time() if now_ts - self._last_touch_ts.get(throttle_key, 0.0) >= self._TOUCH_INTERVAL_SECONDS: self._last_touch_ts[throttle_key] = now_ts if len(self._last_touch_ts) > 4096: cutoff = now_ts - self._TOUCH_INTERVAL_SECONDS self._last_touch_ts = { key: ts for key, ts in self._last_touch_ts.items() if ts >= cutoff } # 只更新内存快照 + 记脏,真正的落库交给后台批量 flush。 touched_at = datetime.now(timezone.utc).isoformat() next_item = dict(matched_item) next_item["last_used_at"] = touched_at self._items[index] = next_item key_id = self._clean(next_item.get("id")) if key_id: self._pending_touch[key_id] = touched_at matched_item = next_item public_item = self._public_item(matched_item) if missed: # 兜底回读放到后台(每 30 秒一轮),本请求立刻返回 401,不阻塞事件循环。 self._ensure_touch_flusher() return None return public_item auth_service = AuthService(config.get_user_storage_backend())