Grant new users a three-day signup trial

This commit is contained in:
2569718930@qq.com
2026-03-29 20:34:12 +08:00
parent 6139960a9e
commit 2b5d2cd801
4 changed files with 270 additions and 3 deletions
+196 -1
View File
@@ -4,7 +4,7 @@ import os
import threading import threading
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional from typing import Dict, List, Optional
import requests import requests
@@ -42,6 +42,7 @@ class SupabaseIdentity:
user_id: str user_id: str
email: str email: str
points: int = 0 points: int = 0
created_at: Optional[str] = None
class SupabaseEntitlementService: class SupabaseEntitlementService:
@@ -64,11 +65,27 @@ class SupabaseEntitlementService:
self.timeout_sec = max(3, _env_int("SUPABASE_HTTP_TIMEOUT_SEC", 8)) self.timeout_sec = max(3, _env_int("SUPABASE_HTTP_TIMEOUT_SEC", 8))
self.cache_ttl_sec = max(5, _env_int("SUPABASE_AUTH_CACHE_TTL_SEC", 30)) self.cache_ttl_sec = max(5, _env_int("SUPABASE_AUTH_CACHE_TTL_SEC", 30))
self.sub_cache_ttl_sec = max(5, _env_int("SUPABASE_SUB_CACHE_TTL_SEC", 60)) self.sub_cache_ttl_sec = max(5, _env_int("SUPABASE_SUB_CACHE_TTL_SEC", 60))
self.signup_trial_enabled = _env_bool(
"POLYWEATHER_SIGNUP_TRIAL_ENABLED",
True,
)
self.signup_trial_days = max(
0,
_env_int("POLYWEATHER_SIGNUP_TRIAL_DAYS", 3),
)
self.signup_trial_plan_code = str(
os.getenv("POLYWEATHER_SIGNUP_TRIAL_PLAN_CODE") or "signup_trial_3d"
).strip() or "signup_trial_3d"
self.signup_trial_source = str(
os.getenv("POLYWEATHER_SIGNUP_TRIAL_SOURCE") or "signup_trial"
).strip() or "signup_trial"
self._identity_cache: Dict[str, Dict[str, object]] = {} self._identity_cache: Dict[str, Dict[str, object]] = {}
self._identity_cache_lock = threading.Lock() self._identity_cache_lock = threading.Lock()
self._sub_cache: Dict[str, Dict[str, object]] = {} self._sub_cache: Dict[str, Dict[str, object]] = {}
self._sub_cache_lock = threading.Lock() self._sub_cache_lock = threading.Lock()
self._trial_locks: Dict[str, threading.Lock] = {}
self._trial_locks_guard = threading.Lock()
def invalidate_subscription_cache(self, user_id: str) -> None: def invalidate_subscription_cache(self, user_id: str) -> None:
key = str(user_id or "").strip() key = str(user_id or "").strip()
@@ -87,6 +104,9 @@ class SupabaseEntitlementService:
def _subscription_endpoint(self) -> str: def _subscription_endpoint(self) -> str:
return f"{self.supabase_url}/rest/v1/subscriptions" return f"{self.supabase_url}/rest/v1/subscriptions"
def _entitlement_events_endpoint(self) -> str:
return f"{self.supabase_url}/rest/v1/entitlement_events"
def _request_headers_for_user(self, access_token: str) -> Dict[str, str]: def _request_headers_for_user(self, access_token: str) -> Dict[str, str]:
return { return {
"apikey": self.anon_key, "apikey": self.anon_key,
@@ -140,6 +160,7 @@ class SupabaseEntitlementService:
user_id=user_id, user_id=user_id,
email=str(data.get("email") or "").strip(), email=str(data.get("email") or "").strip(),
points=points, points=points,
created_at=str(data.get("created_at") or "").strip() or None,
) )
with self._identity_cache_lock: with self._identity_cache_lock:
self._identity_cache[access_token] = { self._identity_cache[access_token] = {
@@ -213,6 +234,180 @@ class SupabaseEntitlementService:
logger.warning(f"supabase subscription query error user_id={user_id}: {exc}") logger.warning(f"supabase subscription query error user_id={user_id}: {exc}")
return None return None
def _query_latest_subscription_any_status(
self,
user_id: str,
) -> Optional[Dict[str, object]]:
if not user_id or not self.service_role_key:
return None
try:
params = {
"select": "id,user_id,status,plan_code,starts_at,expires_at,source,created_at,updated_at",
"user_id": f"eq.{user_id}",
"order": "created_at.desc",
"limit": "1",
}
response = requests.get(
self._subscription_endpoint(),
headers=self._request_headers_for_service_role(),
params=params,
timeout=self.timeout_sec,
)
if response.status_code != 200:
logger.warning(
"supabase subscription history query failed user_id={} status={}",
user_id,
response.status_code,
)
return None
data = response.json() if response.content else []
row = data[0] if isinstance(data, list) and data else None
return row if isinstance(row, dict) else None
except Exception as exc:
logger.warning(f"supabase subscription history query error user_id={user_id}: {exc}")
return None
@staticmethod
def _parse_iso_datetime(raw: Optional[str]) -> Optional[datetime]:
text = str(raw or "").strip()
if not text:
return None
try:
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
except Exception:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _get_trial_lock(self, user_id: str) -> threading.Lock:
key = str(user_id or "").strip()
with self._trial_locks_guard:
lock = self._trial_locks.get(key)
if lock is None:
lock = threading.Lock()
self._trial_locks[key] = lock
return lock
def _emit_signup_trial_event(
self,
*,
user_id: str,
starts_at: datetime,
expires_at: datetime,
) -> None:
if not self.service_role_key:
return
try:
now_iso = datetime.now(timezone.utc).isoformat()
requests.post(
self._entitlement_events_endpoint(),
headers={
**self._request_headers_for_service_role(),
"Content-Type": "application/json",
"Prefer": "return=minimal",
},
json={
"user_id": user_id,
"action": "subscription_granted",
"reason": "signup_trial",
"detail": f"{self.signup_trial_days}d signup trial granted",
"payload": {
"plan_code": self.signup_trial_plan_code,
"source": self.signup_trial_source,
"starts_at": starts_at.isoformat(),
"expires_at": expires_at.isoformat(),
},
"created_at": now_iso,
},
timeout=self.timeout_sec,
)
except Exception as exc:
logger.warning(f"supabase signup trial event insert failed user_id={user_id}: {exc}")
def ensure_signup_trial(
self,
user_id: str,
*,
created_at: Optional[str] = None,
) -> Optional[Dict[str, object]]:
normalized_user_id = str(user_id or "").strip()
if not normalized_user_id:
return None
if (
not self.signup_trial_enabled
or self.signup_trial_days <= 0
or not self.service_role_key
):
return None
lock = self._get_trial_lock(normalized_user_id)
with lock:
existing_active = self._query_latest_active_subscription(normalized_user_id)
if isinstance(existing_active, dict):
return existing_active
existing_any = self._query_latest_subscription_any_status(normalized_user_id)
if isinstance(existing_any, dict):
return None
starts_at = self._parse_iso_datetime(created_at)
if starts_at is None:
admin_users = self.get_auth_users([normalized_user_id])
starts_at = self._parse_iso_datetime(
str((admin_users.get(normalized_user_id) or {}).get("created_at") or "")
)
if starts_at is None:
return None
expires_at = starts_at + timedelta(days=self.signup_trial_days)
now = datetime.now(timezone.utc)
if expires_at <= now:
return None
payload = {
"user_id": normalized_user_id,
"plan_code": self.signup_trial_plan_code,
"status": "active",
"starts_at": starts_at.isoformat(),
"expires_at": expires_at.isoformat(),
"source": self.signup_trial_source,
"created_at": now.isoformat(),
"updated_at": now.isoformat(),
}
try:
response = requests.post(
self._subscription_endpoint(),
headers={
**self._request_headers_for_service_role(),
"Content-Type": "application/json",
"Prefer": "return=representation",
},
json=payload,
timeout=self.timeout_sec,
)
if response.status_code not in (200, 201):
logger.warning(
"supabase signup trial insert failed user_id={} status={}",
normalized_user_id,
response.status_code,
)
return self._query_latest_active_subscription(normalized_user_id)
rows = response.json() if response.content else []
row = rows[0] if isinstance(rows, list) and rows else None
self.invalidate_subscription_cache(normalized_user_id)
self._emit_signup_trial_event(
user_id=normalized_user_id,
starts_at=starts_at,
expires_at=expires_at,
)
if isinstance(row, dict):
return row
return self._query_latest_active_subscription(normalized_user_id)
except Exception as exc:
logger.warning(f"supabase signup trial insert error user_id={normalized_user_id}: {exc}")
return self._query_latest_active_subscription(normalized_user_id)
def _query_active_subscription(self, user_id: str) -> bool: def _query_active_subscription(self, user_id: str) -> bool:
return self._query_latest_active_subscription(user_id) is not None return self._query_latest_active_subscription(user_id) is not None
+66
View File
@@ -0,0 +1,66 @@
from datetime import datetime, timedelta, timezone
import src.auth.supabase_entitlement as entitlement_module
from src.auth.supabase_entitlement import SupabaseEntitlementService
class _Response:
def __init__(self, status_code=200, payload=None):
self.status_code = status_code
self._payload = payload
self.content = b"1"
def json(self):
return self._payload
def test_ensure_signup_trial_grants_three_day_subscription(monkeypatch):
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key")
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
monkeypatch.setenv("POLYWEATHER_SIGNUP_TRIAL_ENABLED", "true")
monkeypatch.setenv("POLYWEATHER_SIGNUP_TRIAL_DAYS", "3")
service = SupabaseEntitlementService()
monkeypatch.setattr(service, "_query_latest_active_subscription", lambda user_id: None)
monkeypatch.setattr(service, "_query_latest_subscription_any_status", lambda user_id: None)
captured_posts = []
def _fake_post(url, headers=None, json=None, timeout=None):
captured_posts.append({"url": url, "headers": headers, "json": json, "timeout": timeout})
if url.endswith("/rest/v1/subscriptions"):
return _Response(
201,
[
{
"user_id": json["user_id"],
"plan_code": json["plan_code"],
"status": json["status"],
"starts_at": json["starts_at"],
"expires_at": json["expires_at"],
"source": json["source"],
}
],
)
return _Response(201, {})
monkeypatch.setattr(entitlement_module.requests, "post", _fake_post)
starts_at = datetime(2026, 3, 29, 8, 0, tzinfo=timezone.utc)
result = service.ensure_signup_trial(
"user-1",
created_at=starts_at.isoformat(),
)
assert result is not None
assert result["plan_code"] == "signup_trial_3d"
assert result["status"] == "active"
assert result["starts_at"] == starts_at.isoformat()
assert result["expires_at"] == (starts_at + timedelta(days=3)).isoformat()
subscription_insert = next(
item for item in captured_posts if item["url"].endswith("/rest/v1/subscriptions")
)
assert subscription_insert["json"]["user_id"] == "user-1"
assert subscription_insert["json"]["source"] == "signup_trial"
+1
View File
@@ -179,6 +179,7 @@ def _bind_optional_supabase_identity(request: Request) -> None:
request.state.auth_user_id = identity.user_id request.state.auth_user_id = identity.user_id
request.state.auth_email = identity.email request.state.auth_email = identity.email
request.state.auth_points = identity.points request.state.auth_points = identity.points
request.state.auth_created_at = identity.created_at
def _resolve_auth_points(request: Request) -> int: def _resolve_auth_points(request: Request) -> int:
+7 -2
View File
@@ -275,10 +275,15 @@ async def auth_me(request: Request):
if SUPABASE_ENTITLEMENT.enabled and user_id: if SUPABASE_ENTITLEMENT.enabled and user_id:
try: try:
latest_subscription = SUPABASE_ENTITLEMENT.get_latest_active_subscription( latest_subscription = SUPABASE_ENTITLEMENT.ensure_signup_trial(
user_id, user_id,
respect_requirement=False, created_at=getattr(request.state, "auth_created_at", None),
) )
if not latest_subscription:
latest_subscription = SUPABASE_ENTITLEMENT.get_latest_active_subscription(
user_id,
respect_requirement=False,
)
if ( if (
not latest_subscription not latest_subscription
and getattr(PAYMENT_CHECKOUT, "enabled", False) and getattr(PAYMENT_CHECKOUT, "enabled", False)