Update referral points pricing
This commit is contained in:
@@ -18,11 +18,13 @@ SIGNUP_TRIAL_PLAN_CODE = "signup_trial_3d"
|
||||
SIGNUP_TRIAL_SOURCE = "signup_trial"
|
||||
SIGNUP_TRIAL_DAYS = 3
|
||||
|
||||
REFERRAL_REWARD_DAYS = 3
|
||||
REFERRAL_REWARD_DAYS = 0
|
||||
REFERRAL_MONTHLY_REWARD_LIMIT = 10
|
||||
REFERRAL_MONTHLY_DAY_LIMIT = 30
|
||||
REFERRAL_DISCOUNT_USDC = "3"
|
||||
REFERRAL_MONTHLY_DISCOUNTED_AMOUNT_USDC = "26.9"
|
||||
REFERRAL_REWARD_POINTS = 3500
|
||||
REFERRAL_MONTHLY_POINTS_LIMIT = REFERRAL_REWARD_POINTS * REFERRAL_MONTHLY_REWARD_LIMIT
|
||||
REFERRAL_DISCOUNT_USDC = "9.9"
|
||||
REFERRAL_MONTHLY_DISCOUNTED_AMOUNT_USDC = "20"
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool = False) -> bool:
|
||||
@@ -204,6 +206,63 @@ class SupabaseEntitlementService:
|
||||
def _admin_user_endpoint(self, user_id: str) -> str:
|
||||
return f"{self.supabase_url}/auth/v1/admin/users/{user_id}"
|
||||
|
||||
@staticmethod
|
||||
def _extract_points_from_metadata(metadata: Optional[Dict[str, object]]) -> int:
|
||||
if not isinstance(metadata, dict):
|
||||
return 0
|
||||
for key in ("points", "total_points"):
|
||||
raw = metadata.get(key)
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
return max(0, int(raw))
|
||||
except Exception:
|
||||
continue
|
||||
return 0
|
||||
|
||||
def _admin_get_user(self, user_id: str) -> Dict[str, object]:
|
||||
user_key = str(user_id or "").strip()
|
||||
if not user_key:
|
||||
raise ValueError("user_id required")
|
||||
response = requests.get(
|
||||
self._admin_user_endpoint(user_key),
|
||||
headers=self._request_headers_for_service_role(),
|
||||
timeout=self.timeout_sec,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
detail = response.text[:350] if response.text else response.reason
|
||||
raise RuntimeError(
|
||||
f"supabase admin user query failed: {response.status_code} {detail}"
|
||||
)
|
||||
raw = response.json() if response.content else {}
|
||||
if isinstance(raw, dict) and isinstance(raw.get("user"), dict):
|
||||
return dict(raw["user"])
|
||||
return dict(raw) if isinstance(raw, dict) else {}
|
||||
|
||||
def _admin_update_user_metadata(
|
||||
self,
|
||||
user_id: str,
|
||||
metadata: Dict[str, object],
|
||||
) -> Dict[str, object]:
|
||||
user_key = str(user_id or "").strip()
|
||||
if not user_key:
|
||||
raise ValueError("user_id required")
|
||||
response = requests.put(
|
||||
self._admin_user_endpoint(user_key),
|
||||
headers={**self._request_headers_for_service_role(), "Content-Type": "application/json"},
|
||||
json={"user_metadata": metadata or {}},
|
||||
timeout=self.timeout_sec,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
detail = response.text[:350] if response.text else response.reason
|
||||
raise RuntimeError(
|
||||
f"supabase admin metadata update failed: {response.status_code} {detail}"
|
||||
)
|
||||
raw = response.json() if response.content else {}
|
||||
if isinstance(raw, dict) and isinstance(raw.get("user"), dict):
|
||||
return dict(raw["user"])
|
||||
return dict(raw) if isinstance(raw, dict) else {}
|
||||
|
||||
@staticmethod
|
||||
def _to_iso(dt: datetime) -> str:
|
||||
return dt.astimezone(timezone.utc).isoformat()
|
||||
@@ -797,7 +856,7 @@ class SupabaseEntitlementService:
|
||||
"GET",
|
||||
"referral_rewards",
|
||||
params={
|
||||
"select": "id,reward_days,created_at",
|
||||
"select": "id,reward_days,reward_points,created_at",
|
||||
"referrer_user_id": f"eq.{referrer_user_id}",
|
||||
"created_at": f"gte.{self._to_iso(month_start)}",
|
||||
"limit": "100",
|
||||
@@ -832,12 +891,32 @@ class SupabaseEntitlementService:
|
||||
{
|
||||
"id": row.get("id"),
|
||||
"reward_days": int(payload.get("reward_days") or REFERRAL_REWARD_DAYS),
|
||||
"reward_points": int(payload.get("reward_points") or REFERRAL_REWARD_POINTS),
|
||||
"created_at": row.get("created_at"),
|
||||
"_storage": "entitlement_events",
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
def _has_referral_reward_for_attribution(self, attribution_id: object) -> bool:
|
||||
raw_id = str(attribution_id or "").strip()
|
||||
if not raw_id or raw_id.startswith("event:"):
|
||||
return False
|
||||
try:
|
||||
rows = self._rest(
|
||||
"GET",
|
||||
"referral_rewards",
|
||||
params={
|
||||
"select": "id",
|
||||
"referral_attribution_id": f"eq.{raw_id}",
|
||||
"limit": "1",
|
||||
},
|
||||
allowed_status=[200],
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
return bool(isinstance(rows, list) and rows)
|
||||
|
||||
def get_referral_summary(self, user_id: str) -> Optional[Dict[str, object]]:
|
||||
user_key = str(user_id or "").strip()
|
||||
if not user_key or not self.service_role_key:
|
||||
@@ -848,15 +927,19 @@ class SupabaseEntitlementService:
|
||||
rewards = self._current_month_reward_rows(user_key)
|
||||
reward_count = len(rewards)
|
||||
reward_days = sum(int(row.get("reward_days") or 0) for row in rewards)
|
||||
reward_points = sum(int(row.get("reward_points") or 0) for row in rewards)
|
||||
return {
|
||||
"code": str(code_row.get("code") or ""),
|
||||
"discount_usdc": REFERRAL_DISCOUNT_USDC,
|
||||
"discounted_monthly_amount_usdc": REFERRAL_MONTHLY_DISCOUNTED_AMOUNT_USDC,
|
||||
"reward_days": REFERRAL_REWARD_DAYS,
|
||||
"reward_points": REFERRAL_REWARD_POINTS,
|
||||
"monthly_reward_limit": REFERRAL_MONTHLY_REWARD_LIMIT,
|
||||
"monthly_reward_days_limit": REFERRAL_MONTHLY_DAY_LIMIT,
|
||||
"monthly_reward_points_limit": REFERRAL_MONTHLY_POINTS_LIMIT,
|
||||
"monthly_reward_count": reward_count,
|
||||
"monthly_reward_days": min(reward_days, REFERRAL_MONTHLY_DAY_LIMIT),
|
||||
"monthly_reward_points": min(reward_points, REFERRAL_MONTHLY_POINTS_LIMIT),
|
||||
"applied_code": str(pending.get("code") or "") if isinstance(pending, dict) else "",
|
||||
"attribution_status": str(pending.get("status") or "") if isinstance(pending, dict) else "",
|
||||
}
|
||||
@@ -947,6 +1030,77 @@ class SupabaseEntitlementService:
|
||||
break
|
||||
return starts
|
||||
|
||||
def _record_points_ledger(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
delta: int,
|
||||
source: str,
|
||||
reason: str,
|
||||
payment_intent_id: str = "",
|
||||
referral_attribution_id: Optional[object] = None,
|
||||
metadata: Optional[Dict[str, object]] = None,
|
||||
) -> None:
|
||||
try:
|
||||
self._rest(
|
||||
"POST",
|
||||
"points_ledger",
|
||||
payload={
|
||||
"user_id": user_id,
|
||||
"delta": int(delta),
|
||||
"source": source,
|
||||
"reason": reason,
|
||||
"payment_intent_id": payment_intent_id or None,
|
||||
"referral_attribution_id": referral_attribution_id,
|
||||
"metadata": metadata or {},
|
||||
"created_at": self._to_iso(datetime.now(timezone.utc)),
|
||||
},
|
||||
prefer="return=minimal",
|
||||
allowed_status=[201],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.info("points ledger write skipped user_id={} reason={}", user_id, exc)
|
||||
|
||||
def _grant_referral_points(
|
||||
self,
|
||||
referrer_user_id: str,
|
||||
points: int,
|
||||
) -> Dict[str, object]:
|
||||
user_key = str(referrer_user_id or "").strip().lower()
|
||||
amount = int(points or 0)
|
||||
if not user_key:
|
||||
return {"ok": False, "reason": "invalid_referrer"}
|
||||
if amount <= 0:
|
||||
return {"ok": False, "reason": "invalid_points"}
|
||||
|
||||
try:
|
||||
db_result = DBManager().grant_points_by_supabase_user_id(user_key, amount)
|
||||
except Exception as exc:
|
||||
db_result = {"ok": False, "reason": f"bot_db_error:{exc}"}
|
||||
if bool(db_result.get("ok")):
|
||||
return {
|
||||
"ok": True,
|
||||
"source": "bot_db",
|
||||
"points_before": int(db_result.get("points_before") or 0),
|
||||
"points_added": amount,
|
||||
"points_after": int(db_result.get("points_after") or 0),
|
||||
}
|
||||
|
||||
user_obj = self._admin_get_user(user_key)
|
||||
metadata = dict(user_obj.get("user_metadata") or {})
|
||||
before = self._extract_points_from_metadata(metadata)
|
||||
after = before + amount
|
||||
metadata["points"] = after
|
||||
metadata["total_points"] = after
|
||||
self._admin_update_user_metadata(user_key, metadata)
|
||||
return {
|
||||
"ok": True,
|
||||
"source": "supabase_metadata",
|
||||
"points_before": before,
|
||||
"points_added": amount,
|
||||
"points_after": after,
|
||||
}
|
||||
|
||||
def _record_referral_resolution_event(
|
||||
self,
|
||||
*,
|
||||
@@ -958,6 +1112,7 @@ class SupabaseEntitlementService:
|
||||
tx_hash: str,
|
||||
created_at: datetime,
|
||||
reward_days: int = 0,
|
||||
reward_points: int = 0,
|
||||
) -> None:
|
||||
self._rest(
|
||||
"POST",
|
||||
@@ -975,6 +1130,7 @@ class SupabaseEntitlementService:
|
||||
"payment_intent_id": payment_intent_id,
|
||||
"tx_hash": tx_hash,
|
||||
"reward_days": reward_days,
|
||||
"reward_points": reward_points,
|
||||
"storage": "entitlement_events",
|
||||
},
|
||||
"created_at": self._to_iso(created_at),
|
||||
@@ -1006,24 +1162,27 @@ class SupabaseEntitlementService:
|
||||
)
|
||||
return {"awarded": False, "reason": "monthly_cap_reached"}
|
||||
|
||||
starts = self._subscription_extension_start(referrer_user_id)
|
||||
expires = starts + timedelta(days=REFERRAL_REWARD_DAYS)
|
||||
subscription_payload = {
|
||||
"user_id": referrer_user_id,
|
||||
"plan_code": "pro_monthly",
|
||||
"status": "active",
|
||||
"starts_at": self._to_iso(starts),
|
||||
"expires_at": self._to_iso(expires),
|
||||
"source": "referral_reward",
|
||||
"created_at": self._to_iso(now),
|
||||
"updated_at": self._to_iso(now),
|
||||
}
|
||||
self._rest(
|
||||
"POST",
|
||||
"subscriptions",
|
||||
payload=subscription_payload,
|
||||
prefer="return=minimal",
|
||||
allowed_status=[201],
|
||||
grant_result = self._grant_referral_points(
|
||||
referrer_user_id,
|
||||
REFERRAL_REWARD_POINTS,
|
||||
)
|
||||
if not bool(grant_result.get("ok")):
|
||||
return {
|
||||
"awarded": False,
|
||||
"reason": str(grant_result.get("reason") or "points_grant_failed"),
|
||||
}
|
||||
self._record_points_ledger(
|
||||
user_id=referrer_user_id,
|
||||
delta=REFERRAL_REWARD_POINTS,
|
||||
source="referral",
|
||||
reason="referred_user_paid",
|
||||
payment_intent_id=payment_intent_id,
|
||||
referral_attribution_id=attribution.get("id"),
|
||||
metadata={
|
||||
"referred_user_id": referred_user_id,
|
||||
"tx_hash": tx_hash,
|
||||
"storage": str(attribution.get("_storage") or "entitlement_events"),
|
||||
},
|
||||
)
|
||||
self._record_referral_resolution_event(
|
||||
action="referral_reward_granted",
|
||||
@@ -1034,6 +1193,7 @@ class SupabaseEntitlementService:
|
||||
tx_hash=tx_hash,
|
||||
created_at=now,
|
||||
reward_days=REFERRAL_REWARD_DAYS,
|
||||
reward_points=REFERRAL_REWARD_POINTS,
|
||||
)
|
||||
self._record_referral_resolution_event(
|
||||
action="referral_attribution_converted",
|
||||
@@ -1044,13 +1204,14 @@ class SupabaseEntitlementService:
|
||||
tx_hash=tx_hash,
|
||||
created_at=now,
|
||||
reward_days=REFERRAL_REWARD_DAYS,
|
||||
reward_points=REFERRAL_REWARD_POINTS,
|
||||
)
|
||||
self.invalidate_subscription_cache(referrer_user_id)
|
||||
return {
|
||||
"awarded": True,
|
||||
"reward_days": REFERRAL_REWARD_DAYS,
|
||||
"reward_points": REFERRAL_REWARD_POINTS,
|
||||
"referrer_user_id": referrer_user_id,
|
||||
"subscription": subscription_payload,
|
||||
"points": grant_result,
|
||||
"storage": "entitlement_events",
|
||||
}
|
||||
|
||||
@@ -1096,40 +1257,80 @@ class SupabaseEntitlementService:
|
||||
allowed_status=[204],
|
||||
)
|
||||
return {"awarded": False, "reason": "monthly_cap_reached"}
|
||||
if self._has_referral_reward_for_attribution(attribution.get("id")):
|
||||
self._rest(
|
||||
"PATCH",
|
||||
"referral_attributions",
|
||||
params={"id": f"eq.{attribution.get('id')}"},
|
||||
payload={
|
||||
"status": "converted",
|
||||
"converted_payment_intent_id": payment_intent_id,
|
||||
"converted_tx_hash": tx_hash,
|
||||
"converted_at": self._to_iso(now),
|
||||
"updated_at": self._to_iso(now),
|
||||
},
|
||||
prefer="return=minimal",
|
||||
allowed_status=[204],
|
||||
)
|
||||
return {"awarded": False, "reason": "already_rewarded"}
|
||||
|
||||
starts = self._subscription_extension_start(referrer_key)
|
||||
expires = starts + timedelta(days=REFERRAL_REWARD_DAYS)
|
||||
subscription_payload = {
|
||||
"user_id": referrer_key,
|
||||
"plan_code": "pro_monthly",
|
||||
"status": "active",
|
||||
"starts_at": self._to_iso(starts),
|
||||
"expires_at": self._to_iso(expires),
|
||||
"source": "referral_reward",
|
||||
"created_at": self._to_iso(now),
|
||||
"updated_at": self._to_iso(now),
|
||||
}
|
||||
self._rest(
|
||||
"POST",
|
||||
"subscriptions",
|
||||
payload=subscription_payload,
|
||||
prefer="return=minimal",
|
||||
allowed_status=[201],
|
||||
grant_result = self._grant_referral_points(
|
||||
referrer_key,
|
||||
REFERRAL_REWARD_POINTS,
|
||||
)
|
||||
self._rest(
|
||||
"POST",
|
||||
"referral_rewards",
|
||||
payload={
|
||||
if not bool(grant_result.get("ok")):
|
||||
return {
|
||||
"awarded": False,
|
||||
"reason": str(grant_result.get("reason") or "points_grant_failed"),
|
||||
}
|
||||
|
||||
reward_payload = {
|
||||
"referral_attribution_id": attribution.get("id"),
|
||||
"referrer_user_id": referrer_key,
|
||||
"referred_user_id": referred_key,
|
||||
"payment_intent_id": payment_intent_id,
|
||||
"tx_hash": tx_hash,
|
||||
"reward_days": REFERRAL_REWARD_DAYS,
|
||||
"reward_points": REFERRAL_REWARD_POINTS,
|
||||
"created_at": self._to_iso(now),
|
||||
}
|
||||
try:
|
||||
self._rest(
|
||||
"POST",
|
||||
"referral_rewards",
|
||||
payload=reward_payload,
|
||||
prefer="return=minimal",
|
||||
allowed_status=[201],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"referral_rewards insert failed attribution_id={} error={}",
|
||||
attribution.get("id"),
|
||||
exc,
|
||||
)
|
||||
self._record_referral_resolution_event(
|
||||
action="referral_reward_granted",
|
||||
referrer_user_id=referrer_key,
|
||||
referred_user_id=referred_key,
|
||||
attribution=attribution,
|
||||
payment_intent_id=payment_intent_id,
|
||||
tx_hash=tx_hash,
|
||||
created_at=now,
|
||||
reward_days=REFERRAL_REWARD_DAYS,
|
||||
reward_points=REFERRAL_REWARD_POINTS,
|
||||
)
|
||||
self._record_points_ledger(
|
||||
user_id=referrer_key,
|
||||
delta=REFERRAL_REWARD_POINTS,
|
||||
source="referral",
|
||||
reason="referred_user_paid",
|
||||
payment_intent_id=payment_intent_id,
|
||||
referral_attribution_id=attribution.get("id"),
|
||||
metadata={
|
||||
"referred_user_id": referred_key,
|
||||
"tx_hash": tx_hash,
|
||||
"grant_source": str(grant_result.get("source") or ""),
|
||||
},
|
||||
prefer="return=minimal",
|
||||
allowed_status=[201],
|
||||
)
|
||||
self._rest(
|
||||
"PATCH",
|
||||
@@ -1145,31 +1346,23 @@ class SupabaseEntitlementService:
|
||||
prefer="return=minimal",
|
||||
allowed_status=[204],
|
||||
)
|
||||
self._rest(
|
||||
"POST",
|
||||
"entitlement_events",
|
||||
payload={
|
||||
"user_id": referrer_key,
|
||||
"action": "referral_reward_granted",
|
||||
"reason": "referred_user_paid",
|
||||
"actor": "payment_contract_checkout",
|
||||
"payload": {
|
||||
"referred_user_id": referred_key,
|
||||
"payment_intent_id": payment_intent_id,
|
||||
"tx_hash": tx_hash,
|
||||
"reward_days": REFERRAL_REWARD_DAYS,
|
||||
},
|
||||
"created_at": self._to_iso(now),
|
||||
},
|
||||
prefer="return=minimal",
|
||||
allowed_status=[201],
|
||||
self._record_referral_resolution_event(
|
||||
action="referral_attribution_converted",
|
||||
referrer_user_id=referrer_key,
|
||||
referred_user_id=referred_key,
|
||||
attribution=attribution,
|
||||
payment_intent_id=payment_intent_id,
|
||||
tx_hash=tx_hash,
|
||||
created_at=now,
|
||||
reward_days=REFERRAL_REWARD_DAYS,
|
||||
reward_points=REFERRAL_REWARD_POINTS,
|
||||
)
|
||||
self.invalidate_subscription_cache(referrer_key)
|
||||
return {
|
||||
"awarded": True,
|
||||
"reward_days": REFERRAL_REWARD_DAYS,
|
||||
"reward_points": REFERRAL_REWARD_POINTS,
|
||||
"referrer_user_id": referrer_key,
|
||||
"subscription": subscription_payload,
|
||||
"points": grant_result,
|
||||
}
|
||||
|
||||
def get_identity(self, access_token: str) -> Optional[SupabaseIdentity]:
|
||||
|
||||
@@ -91,7 +91,7 @@ class CommandGuard:
|
||||
used = int(result.get("used") or 0)
|
||||
self.io_layer.send_query_message(
|
||||
message,
|
||||
f"❌ 今日 <b>/{query_type}</b> 免费次数已用完 ({used}/{limit})\n请明天再来,或通过群内发言赚取积分。",
|
||||
f"❌ 今日 <b>/{query_type}</b> 免费次数已用完 ({used}/{limit})\n请明天再来;积分可通过邀请付费用户获得。",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return False
|
||||
|
||||
+12
-29
@@ -9,6 +9,7 @@ from loguru import logger
|
||||
from src.bot.settings import (
|
||||
CITY_DAILY_FREE_LIMIT,
|
||||
DEB_DAILY_FREE_LIMIT,
|
||||
GROUP_MESSAGE_POINTS_ENABLED,
|
||||
MESSAGE_COOLDOWN_SEC,
|
||||
MESSAGE_DAILY_CAP,
|
||||
MESSAGE_MIN_LENGTH,
|
||||
@@ -184,8 +185,8 @@ class BotIOLayer:
|
||||
f"当前积分: <code>{balance}</code>\n"
|
||||
f"需要积分: <code>{required}</code>\n"
|
||||
f"还差积分: <code>{missing}</code>\n\n"
|
||||
f"积分规则:有效发言满 {MESSAGE_MIN_LENGTH} 字获得 <b>{MESSAGE_POINTS}</b> 积分,"
|
||||
f"每日上限 {MESSAGE_DAILY_CAP} 分。"
|
||||
"积分现在通过邀请制度获得:被邀请人完成首次 Pro 付款后,"
|
||||
"邀请人获得积分奖励。"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
@@ -207,9 +208,7 @@ class BotIOLayer:
|
||||
"👥 社群: <a href=\"https://t.me/+Io5H9oVHFmVjOTQ5\">加入 Telegram 群组</a>\n\n"
|
||||
"📌 <i>私有频道用于接收自动推送;手动查看市场概览请私聊机器人发送 <code>/markets</code>。</i>\n\n"
|
||||
"示例: <code>/city 伦敦</code> 或 <code>/pwcity 伦敦</code>\n"
|
||||
f"💡 <i>提示: 群内有效发言(满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
|
||||
f"每日上限 {MESSAGE_DAILY_CAP} 分。"
|
||||
f"首次发言额外奖励 <b>20</b> 积分,每日首条消息 +<b>2</b> 积分。</i>"
|
||||
"💡 <i>提示: 积分现在通过邀请制度获得;有效邀请完成首次 Pro 付款后,邀请人获得积分奖励。</i>"
|
||||
)
|
||||
|
||||
def build_points_rank_text(self, user: Any) -> str:
|
||||
@@ -217,33 +216,17 @@ class BotIOLayer:
|
||||
user_info = self.db.get_user(user.id)
|
||||
now = datetime.now()
|
||||
today_str = now.strftime("%Y-%m-%d")
|
||||
weekly_profile = self.db.get_weekly_profile(user.id)
|
||||
week_key = str(weekly_profile.get("week_key") or "")
|
||||
|
||||
leaderboard = self.db.get_weekly_leaderboard(limit=5)
|
||||
rank_text = f"🏆 <b>PolyWeather 周活跃度排行榜 ({week_key})</b>\n"
|
||||
leaderboard = self.db.get_leaderboard(limit=5)
|
||||
rank_text = "🏆 <b>PolyWeather 用户积分排行</b>\n"
|
||||
rank_text += "────────────────────\n"
|
||||
for i, entry in enumerate(leaderboard):
|
||||
medal = ["🥇", "🥈", "🥉", " ", " "][i] if i < 5 else " "
|
||||
username = (entry.get("username") or "unknown")[:12]
|
||||
weekly_points = int(entry.get("weekly_points") or 0)
|
||||
rank_text += f"{medal} {username}: <b>{weekly_points}</b> 点\n"
|
||||
points = int(entry.get("points") or 0)
|
||||
rank_text += f"{medal} {username}: <b>{points}</b> 分\n"
|
||||
|
||||
if user_info:
|
||||
daily_points = int(user_info.get("daily_points") or 0)
|
||||
daily_points_date = str(user_info.get("daily_points_date") or "")
|
||||
if daily_points_date != today_str:
|
||||
daily_points = 0
|
||||
if daily_points > MESSAGE_DAILY_CAP:
|
||||
daily_points = MESSAGE_DAILY_CAP
|
||||
|
||||
weekly_points = int(weekly_profile.get("weekly_points") or 0)
|
||||
weekly_rank = weekly_profile.get("weekly_rank")
|
||||
ranked_count = int(weekly_profile.get("total_ranked") or 0)
|
||||
weekly_rank_text = (
|
||||
f"{weekly_rank}/{ranked_count}" if weekly_rank and ranked_count > 0 else "未上榜"
|
||||
)
|
||||
|
||||
daily_queries_date = str(user_info.get("daily_queries_date") or "")
|
||||
city_used = int(user_info.get("daily_city_queries") or 0) if daily_queries_date == today_str else 0
|
||||
deb_used = int(user_info.get("daily_deb_queries") or 0) if daily_queries_date == today_str else 0
|
||||
@@ -252,15 +235,15 @@ class BotIOLayer:
|
||||
rank_text += (
|
||||
"👤 <b>我的状态:</b>\n"
|
||||
f"┣ 累计积分: <code>{user_info['points']}</code>\n"
|
||||
f"┣ 累计发言: <code>{user_info['message_count']}</code> 次\n"
|
||||
f"┣ 本周排名: <code>{weekly_rank_text}</code>\n"
|
||||
f"┣ 本周发言积分: <code>{weekly_points}</code>\n"
|
||||
f"┣ 今日发言积分: <code>{daily_points}/{MESSAGE_DAILY_CAP}</code>\n"
|
||||
"┣ 积分获取: <code>邀请付费用户</code>\n"
|
||||
"┣ 抵扣规则: <code>500分 = 1 USDC,单笔最多抵3U</code>\n"
|
||||
f"┗ /city 免费 ({city_used}/{CITY_DAILY_FREE_LIMIT}) | /deb 免费 ({deb_used}/{DEB_DAILY_FREE_LIMIT})"
|
||||
)
|
||||
return rank_text
|
||||
|
||||
def track_group_text_activity(self, message: Any) -> None:
|
||||
if not GROUP_MESSAGE_POINTS_ENABLED or MESSAGE_POINTS <= 0:
|
||||
return
|
||||
text = str(getattr(message, "text", "") or "")
|
||||
if text.startswith("/"):
|
||||
return
|
||||
|
||||
@@ -188,7 +188,7 @@ class StartupCoordinator:
|
||||
)
|
||||
|
||||
def _start_weekly_reward_loop(self) -> LoopStatus:
|
||||
enabled = _env_bool("POLYWEATHER_WEEKLY_REWARD_ENABLED", True)
|
||||
enabled = _env_bool("POLYWEATHER_WEEKLY_REWARD_ENABLED", False)
|
||||
chat_ids = get_telegram_chat_ids_from_env()
|
||||
settle_weekday = min(
|
||||
7, max(1, _env_int("POLYWEATHER_WEEKLY_REWARD_SETTLE_WEEKDAY", 1))
|
||||
|
||||
+12
-1
@@ -14,7 +14,18 @@ def _env_int(name: str, default: int, min_value: int = 0) -> int:
|
||||
return max(min_value, value)
|
||||
|
||||
|
||||
MESSAGE_POINTS = _env_int("POLYWEATHER_BOT_MESSAGE_POINTS", 4, min_value=1)
|
||||
def _env_bool(name: str, default: bool = False) -> bool:
|
||||
raw = os.getenv(name)
|
||||
if raw is None:
|
||||
return default
|
||||
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
GROUP_MESSAGE_POINTS_ENABLED = _env_bool(
|
||||
"POLYWEATHER_BOT_GROUP_MESSAGE_POINTS_ENABLED",
|
||||
False,
|
||||
)
|
||||
MESSAGE_POINTS = _env_int("POLYWEATHER_BOT_MESSAGE_POINTS", 0, min_value=0)
|
||||
MESSAGE_DAILY_CAP = _env_int("POLYWEATHER_BOT_MESSAGE_DAILY_CAP", 40, min_value=1)
|
||||
MESSAGE_MIN_LENGTH = _env_int("POLYWEATHER_BOT_MESSAGE_MIN_LENGTH", 3, min_value=1)
|
||||
MESSAGE_COOLDOWN_SEC = _env_int("POLYWEATHER_BOT_MESSAGE_COOLDOWN_SEC", 30, min_value=0)
|
||||
|
||||
@@ -243,7 +243,7 @@ def _render_settle_report(
|
||||
|
||||
|
||||
def _runner(bot: Any) -> None:
|
||||
enabled = _env_bool("POLYWEATHER_WEEKLY_REWARD_ENABLED", True)
|
||||
enabled = _env_bool("POLYWEATHER_WEEKLY_REWARD_ENABLED", False)
|
||||
if not enabled:
|
||||
logger.info("weekly reward loop disabled")
|
||||
return
|
||||
|
||||
@@ -1442,6 +1442,59 @@ class DBManager:
|
||||
"points_after": after,
|
||||
}
|
||||
|
||||
def grant_points_by_supabase_user_id(
|
||||
self,
|
||||
supabase_user_id: str,
|
||||
amount: int,
|
||||
) -> Dict[str, Any]:
|
||||
key = str(supabase_user_id or "").strip().lower()
|
||||
points = int(amount or 0)
|
||||
if not key:
|
||||
return {"ok": False, "reason": "invalid_supabase_user_id"}
|
||||
if points <= 0:
|
||||
return {"ok": False, "reason": "invalid_amount"}
|
||||
|
||||
with self._get_connection() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
telegram_id = self._find_telegram_id_by_supabase_user_id(conn, key)
|
||||
if telegram_id is None:
|
||||
return {"ok": False, "reason": "user_not_found", "supabase_user_id": key}
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT telegram_id, username, points, supabase_email
|
||||
FROM users
|
||||
WHERE telegram_id = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(int(telegram_id),),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return {"ok": False, "reason": "user_not_found", "supabase_user_id": key}
|
||||
|
||||
telegram_id = int(row["telegram_id"] or 0)
|
||||
before = int(row["points"] or 0)
|
||||
after = before + points
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET points = ?
|
||||
WHERE telegram_id = ?
|
||||
""",
|
||||
(after, telegram_id),
|
||||
)
|
||||
conn.commit()
|
||||
self._sync_points_to_supabase_user_metadata(telegram_id, force=True)
|
||||
return {
|
||||
"ok": True,
|
||||
"telegram_id": telegram_id,
|
||||
"username": str(row["username"] or ""),
|
||||
"supabase_user_id": key,
|
||||
"supabase_email": str(row["supabase_email"] or ""),
|
||||
"points_before": before,
|
||||
"points_added": points,
|
||||
"points_after": after,
|
||||
}
|
||||
|
||||
def deduct_points_by_supabase_email(
|
||||
self,
|
||||
supabase_email: str,
|
||||
|
||||
@@ -95,7 +95,11 @@ DEFAULT_PLAN_CATALOG: Dict[str, Dict[str, Any]] = {
|
||||
"pro_quarterly": {"plan_id": 102, "amount_usdc": "79.9", "duration_days": 90},
|
||||
}
|
||||
|
||||
REFERRAL_FIRST_MONTH_DISCOUNT_USDC = Decimal("3")
|
||||
REFERRAL_FIRST_MONTH_DISCOUNT_USDC = Decimal("9.9")
|
||||
DEFAULT_POINTS_MAX_DISCOUNT_BY_PLAN: Dict[str, int] = {
|
||||
"pro_monthly": 3,
|
||||
"pro_quarterly": 8,
|
||||
}
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool = False) -> bool:
|
||||
@@ -208,6 +212,29 @@ def _parse_allowed_plan_codes(raw: str) -> List[str]:
|
||||
return out or ["pro_monthly", "pro_quarterly"]
|
||||
|
||||
|
||||
def _parse_points_max_discount_by_plan(raw: str, fallback: int) -> Dict[str, int]:
|
||||
if not raw:
|
||||
return dict(DEFAULT_POINTS_MAX_DISCOUNT_BY_PLAN)
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except Exception:
|
||||
return dict(DEFAULT_POINTS_MAX_DISCOUNT_BY_PLAN)
|
||||
if not isinstance(parsed, dict):
|
||||
return dict(DEFAULT_POINTS_MAX_DISCOUNT_BY_PLAN)
|
||||
|
||||
out: Dict[str, int] = {}
|
||||
for plan_code, raw_value in parsed.items():
|
||||
code = str(plan_code or "").strip().lower()
|
||||
if not code:
|
||||
continue
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except Exception:
|
||||
value = fallback
|
||||
out[code] = max(0, value)
|
||||
return out or dict(DEFAULT_POINTS_MAX_DISCOUNT_BY_PLAN)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WalletBindingRecord:
|
||||
chain_id: int
|
||||
@@ -378,6 +405,11 @@ class PaymentContractCheckoutService:
|
||||
self.points_max_discount_usdc = max(
|
||||
0, _env_int("POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC", 3)
|
||||
)
|
||||
self.points_max_discount_usdc_by_plan = _parse_points_max_discount_by_plan(
|
||||
os.getenv("POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC_BY_PLAN_JSON")
|
||||
or "",
|
||||
self.points_max_discount_usdc,
|
||||
)
|
||||
self._w3_lock = threading.Lock()
|
||||
self._w3: Optional[Web3] = None
|
||||
self._w3_url: str = ""
|
||||
@@ -919,6 +951,12 @@ class PaymentContractCheckoutService:
|
||||
balance = self._extract_points_from_metadata(metadata)
|
||||
return {"source": "supabase_metadata", "balance": balance, "metadata": metadata}
|
||||
|
||||
def _points_max_discount_for_plan(self, plan_code: str) -> int:
|
||||
code = str(plan_code or "").strip().lower()
|
||||
if code in self.points_max_discount_usdc_by_plan:
|
||||
return max(0, int(self.points_max_discount_usdc_by_plan[code]))
|
||||
return max(0, int(self.points_max_discount_usdc))
|
||||
|
||||
def _auth_admin_get_user(self, user_id: str) -> Dict[str, Any]:
|
||||
user_id_text = str(user_id or "").strip()
|
||||
if not user_id_text:
|
||||
@@ -961,15 +999,21 @@ class PaymentContractCheckoutService:
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
plan_code: str,
|
||||
plan_amount_usdc: Decimal,
|
||||
use_points: bool,
|
||||
requested_points_to_consume: Optional[int],
|
||||
) -> Dict[str, Any]:
|
||||
max_discount_for_plan = self._points_max_discount_for_plan(plan_code)
|
||||
base = {
|
||||
"enabled": bool(self.points_enabled),
|
||||
"applied": False,
|
||||
"points_per_usdc": int(self.points_per_usdc),
|
||||
"max_discount_usdc": int(self.points_max_discount_usdc),
|
||||
"max_discount_usdc": int(max_discount_for_plan),
|
||||
"max_discount_usdc_by_plan": {
|
||||
str(code): int(value)
|
||||
for code, value in self.points_max_discount_usdc_by_plan.items()
|
||||
},
|
||||
"points_source": "supabase_metadata",
|
||||
"points_balance_snapshot": 0,
|
||||
"points_to_consume": 0,
|
||||
@@ -990,7 +1034,7 @@ class PaymentContractCheckoutService:
|
||||
return base
|
||||
|
||||
max_discount_usdc = min(
|
||||
Decimal(int(self.points_max_discount_usdc)),
|
||||
Decimal(int(max_discount_for_plan)),
|
||||
plan_amount_usdc,
|
||||
)
|
||||
max_points_by_plan = int(
|
||||
@@ -1266,6 +1310,10 @@ class PaymentContractCheckoutService:
|
||||
"enabled": bool(self.points_enabled),
|
||||
"points_per_usdc": int(self.points_per_usdc),
|
||||
"max_discount_usdc": int(self.points_max_discount_usdc),
|
||||
"max_discount_usdc_by_plan": {
|
||||
str(plan_code): int(self._points_max_discount_for_plan(plan_code))
|
||||
for plan_code in sorted(self.plan_catalog.keys())
|
||||
},
|
||||
},
|
||||
"plans": [
|
||||
{
|
||||
@@ -1789,10 +1837,12 @@ class PaymentContractCheckoutService:
|
||||
"amount_before_discount_usdc_decimal",
|
||||
plan_amount_usdc,
|
||||
)
|
||||
referral_discount_applied = isinstance(plan.get("referral_discount"), dict)
|
||||
redemption = self._build_points_redemption(
|
||||
user_id=user_id,
|
||||
plan_code=str(plan.get("plan_code") or plan_code),
|
||||
plan_amount_usdc=plan_amount_usdc,
|
||||
use_points=bool(use_points),
|
||||
use_points=bool(use_points) and not referral_discount_applied,
|
||||
requested_points_to_consume=points_to_consume,
|
||||
)
|
||||
final_amount_usdc = redemption["pay_amount_usdc"]
|
||||
@@ -1831,7 +1881,8 @@ class PaymentContractCheckoutService:
|
||||
redemption.get("points_per_usdc") or self.points_per_usdc
|
||||
),
|
||||
"max_discount_usdc": int(
|
||||
redemption.get("max_discount_usdc") or self.points_max_discount_usdc
|
||||
redemption.get("max_discount_usdc")
|
||||
or self._points_max_discount_for_plan(str(plan.get("plan_code") or plan_code))
|
||||
),
|
||||
"points_source": str(
|
||||
redemption.get("points_source") or "supabase_metadata"
|
||||
|
||||
Reference in New Issue
Block a user