Update referral points pricing
This commit is contained in:
+2
-1
@@ -200,7 +200,8 @@ POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_POINTS_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_POINTS_PER_USDC=500
|
||||
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3
|
||||
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly
|
||||
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC_BY_PLAN_JSON={"pro_monthly":3,"pro_quarterly":8}
|
||||
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly,pro_quarterly
|
||||
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON=
|
||||
POLYMARKET_SIGNAL_EDGE_PCT=2
|
||||
|
||||
|
||||
+6
-6
@@ -14,16 +14,16 @@
|
||||
|
||||

|
||||
|
||||
## 当前产品状态(2026-05-29)
|
||||
## 当前产品状态(2026-05-30)
|
||||
|
||||
- 已上线订阅制:`Pro 月付 10 USDC`。
|
||||
- 已上线积分体系:群内发言赚分 + 首次发言欢迎奖励 (+20) + 每日首条消息奖励 (+2) + 每周全员参与奖。
|
||||
- `/city` 与 `/deb` 已改为免费(每日各 10 次);积分可用于支付抵扣(`500 分 = 1 USDC`,最多抵 `3 USDC`)。
|
||||
- 周榜奖励已改造:降低赢家积分加成 (200/100/50),所有周活跃用户均享参与奖。
|
||||
- 已上线订阅制:`Pro 月付 29.9 USDC / 30 天`,`Pro 季度 79.9 USDC / 90 天`。
|
||||
- 积分获取已切换为邀请制度:被邀请人完成首次 Pro 付款后,邀请人获得 `3500` 积分;Telegram 群发言不再获得积分。
|
||||
- `/city` 与 `/deb` 已改为免费(每日各 10 次);积分可用于支付抵扣(`500 分 = 1 USDC`,月付最多抵 `3 USDC`,季度最多抵 `8 USDC`)。
|
||||
- 邀请首月价:被邀请人首次月付 `20 USDC`;每个邀请人每月最多 10 个有效付费邀请奖励。
|
||||
- 已上线链上支付:Polygon 合约支付(USDC / USDC.e)+ Ethereum 主网 USDC 直转确认。
|
||||
- 已上线自动补单:事件监听 + 周期确认双链路。
|
||||
- 已上线支付运行态与审计接口:`/api/payments/runtime`。
|
||||
- 已上线轻量运营后台:`/ops`(会员、周榜、补分、支付异常单)。
|
||||
- 已上线轻量运营后台:`/ops`(会员、积分、补分、支付异常单)。
|
||||
- 已上线轻量可观测性:`/healthz`、`/api/system/status`、`/metrics`。
|
||||
- 已补最小外部监控栈:Prometheus + Alertmanager + Grafana + Telegram 告警 relay。
|
||||
- 实时终端已切换到可重放事件流:可见城市图表通过 `/api/events?cities=...&since_revision=...` 订阅 `city_observation_patch.v1`,生产环境使用 Redis Stream 做短窗口 replay,本地/单进程可回退 SQLite event log。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 商业化说明(Production)
|
||||
|
||||
最后更新:`2026-05-29`
|
||||
最后更新:`2026-05-30`
|
||||
|
||||
## 1. 定位
|
||||
|
||||
@@ -18,8 +18,9 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
|
||||
| 能力 | 状态 | 备注 |
|
||||
| :-- | :-- | :-- |
|
||||
| 登录注册(Google + 邮箱) | 已上线 | Supabase 鉴权 |
|
||||
| 订阅套餐(Pro 月付) | 已上线 | `10 USDC / 30天` |
|
||||
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U` |
|
||||
| 订阅套餐(Pro 月付 / 季度) | 已上线 | `29.9 USDC / 30天`;`79.9 USDC / 90天` |
|
||||
| 邀请积分 | 已上线 | 被邀请人首月 `20U`;邀请人每个有效付费邀请 `+3500` 积分 |
|
||||
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U`;邀请首月价不叠加积分抵扣 |
|
||||
| 合约支付 | 已上线 | Polygon,USDC + USDC.e |
|
||||
| 多链直转支付 | 已上线 | Ethereum 主网 USDC 直转确认 |
|
||||
| 支付自动确认 | 已上线 | Event Loop + Confirm Loop,按 intent.chain_id 查链 |
|
||||
@@ -42,9 +43,11 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
|
||||
|
||||
## 4. 收费与积分规则(默认)
|
||||
|
||||
- 套餐:`pro_monthly`(10 USDC / 30 天)
|
||||
- 抵扣:500 积分抵 1 USDC,最高抵 3 USDC
|
||||
- 实付下限:2 USDC(当积分满额时)
|
||||
- 套餐:`pro_monthly`(29.9 USDC / 30 天)、`pro_quarterly`(79.9 USDC / 90 天)
|
||||
- 邀请首月:被邀请人首次月付 20 USDC
|
||||
- 邀请奖励:邀请人 +3500 积分,每月最多 10 个有效付费邀请
|
||||
- 抵扣:500 积分抵 1 USDC;月付最高抵 3 USDC,季度最高抵 8 USDC
|
||||
- 积分来源:仅通过有效付费邀请和后台人工补发,不再通过 Telegram 群发言发放
|
||||
|
||||
> 说明:具体运营策略可按阶段调整,生产参数建议放私有仓库。
|
||||
|
||||
|
||||
@@ -92,14 +92,15 @@ POLYWEATHER_PAYMENT_MAX_WAIT_SEC=50
|
||||
# Ethereum 主网 USDC 当前建议只开 direct transfer,不走 Polygon checkout 合约。
|
||||
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=[{"code":"usdc_polygon","symbol":"USDC","name":"USDC on Polygon","chain_id":137,"chain_code":"polygon","chain_name":"Polygon","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0x<receiver_contract>","direct_receiver_address":"0x<treasury_or_receiver_wallet>","is_default":true},{"code":"usdc_e_polygon","symbol":"USDC.e","name":"USDC.e on Polygon","chain_id":137,"chain_code":"polygon","chain_name":"Polygon","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0x<receiver_contract>","direct_receiver_address":"0x<treasury_or_receiver_wallet>"},{"code":"usdc_ethereum","symbol":"USDC","name":"USDC on Ethereum","chain_id":1,"chain_code":"ethereum","chain_name":"Ethereum Mainnet","address":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48","decimals":6,"direct_receiver_address":"0x<treasury_or_receiver_wallet>","supports_contract_checkout":false,"supports_direct_transfer":true,"confirmations":2,"explorer_tx_url":"https://etherscan.io/tx/{tx_hash}"}]
|
||||
|
||||
# 套餐(当前只保留月付)
|
||||
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON={"pro_monthly":{"plan_id":101,"amount_usdc":"10","duration_days":30}}
|
||||
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly
|
||||
# 套餐
|
||||
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON={"pro_monthly":{"plan_id":101,"amount_usdc":"29.9","duration_days":30},"pro_quarterly":{"plan_id":102,"amount_usdc":"79.9","duration_days":90}}
|
||||
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly,pro_quarterly
|
||||
|
||||
# 积分抵扣
|
||||
POLYWEATHER_PAYMENT_POINTS_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_POINTS_PER_USDC=500
|
||||
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3
|
||||
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC_BY_PLAN_JSON={"pro_monthly":3,"pro_quarterly":8}
|
||||
|
||||
# 支付自动补单
|
||||
POLYWEATHER_PAYMENT_EVENT_LOOP_ENABLED=true
|
||||
|
||||
@@ -60,7 +60,7 @@ flowchart TD
|
||||
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(发言/首次消息奖励/欢迎奖励/周排名奖励/周参与奖/手动补分) |
|
||||
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(有效付费邀请/后台人工补发/积分抵扣消费) |
|
||||
| 支付合约 V2 升级 | 当前仍是最小可用合约 | 升级到 SafeERC20 + Pausable + plan 绑定 |
|
||||
| 支付失败文案标准化 | 转化率受影响 | 建立错误码 -> 文案映射表 |
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ DEB(Dynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加
|
||||
趋势/概率引擎在 `trend_engine.py` 中实现:综合“集合预报区间→σ/μ→高温窗口→死盘判定→温度桶概率分布→边界提示”等,用于 bot 展示与 web 结构化数据输出。
|
||||
**城市决策层(Scan Terminal / 结构化实况层)**:地图点击城市后加入城市决策卡,前端拉取 full detail、多模型区间、最新 METAR/官方站点/跑道观测,并通过 `/api/city/{name}/detail` 生成城市级结构化数据。终端图表使用 HTTP snapshot + SSE patch + Redis/SQLite replay,最高温中枢优先使用 DEB hourly consensus,再结合多模型中心、日内 pace 和当前实测。
|
||||
**市场层(Polymarket 行情对照)**:*[v1.7.0 已移除]* 原先从 Gamma API 发现市场、从 CLOB 读取价格/盘口并计算”模型-市场差”,已于 2026-05-23 随 Polymarket 价格拉取层一并删除。当前 `market_scan` 返回空。
|
||||
**商业化与支付**:订阅(`Pro Monthly 10 USDC`)、积分抵扣、Polygon 链上收款合约(USDC/USDC.e)、Ethereum 主网 USDC 直转确认,并提供“事件监听 + 周期确认”的自动补单机制。
|
||||
**商业化与支付**:订阅(`Pro Monthly 29.9 USDC / 30 天`、`Pro Quarterly 79.9 USDC / 90 天`)、邀请积分抵扣、Polygon 链上收款合约(USDC/USDC.e)、Ethereum 主网 USDC 直转确认,并提供“事件监听 + 周期确认”的自动补单机制。
|
||||
**支持的数据集/数据源**:项目不是传统“训练数据集+模型训练”的机器学习仓库;其“数据集”本质是外部实时/预报 API 与站点观测数据。对外部数据的使用需要遵守来源方的访问与速率限制,例如 AviationWeather Data API 明确限制请求频率(含每分钟请求上限/建议降低频率与使用缓存文件)。
|
||||
**许可证**:仓库根目录 `LICENSE` 当前为 `AGPL-3.0-only`。同时 README 与策略文档明确:品牌、商标、生产私有数据与运营策略不随代码许可证一并授权。
|
||||
(插图:项目 README 中包含产品截图,可用于快速理解实时终端与 Telegram 推送形态)
|
||||
@@ -148,7 +148,7 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
|
||||
|
||||
### 优势
|
||||
|
||||
**产品闭环完整、目标明确**:从“天气→结算→市场→错价信号→付费体系(订阅/积分/链上支付)”形成可商业化闭环,并在 README 清晰列出当前产品状态(订阅、积分抵扣、链上支付、自动补单等已上线)。2026-05 完成积分制度改造:`/city` `/deb` 改为免费(每日各 10 次),新增首次发言欢迎奖励与每日首条消息奖励,周奖励降低赢家积分差距并增加全员参与奖。
|
||||
**产品闭环完整、目标明确**:从“天气→结算→市场→错价信号→付费体系(订阅/积分/链上支付)”形成可商业化闭环,并在 README 清晰列出当前产品状态(订阅、积分抵扣、链上支付、自动补单等已上线)。2026-05 完成积分制度改造:`/city` `/deb` 改为免费(每日各 10 次),积分获取切换为有效付费邀请奖励;Telegram 群发言不再发放积分。
|
||||
**复用一套分析内核服务多端**:趋势/概率/DEB 等核心逻辑被抽成分析模块,并被 web 与 bot 共用,避免“两套逻辑漂移”。前端城市决策卡在此基础上补足“机场报文解释 + 市场桶动作口径”,让用户从地图点击可以直接进入可解释决策。
|
||||
**面向外部 API 的工程防护意识较强**:Open-Meteo 429 冷却期、最小调用间隔、磁盘缓存、缓存 TTL 等措施表明作者已遭遇并处理速率限制与冷启动问题。 同时 AviationWeather 官方文档也明确建议控制频率并可使用 cache 文件降低负载,项目后续可进一步对齐最佳实践。
|
||||
**支付侧有“事件监听 + 确认补单”的双通路**:支付链路天然存在“交易 pending / RPC 延迟 / 日志索引不完整”等问题,项目通过 event loop 与 confirm loop 双机制提升最终一致性。
|
||||
|
||||
@@ -12,7 +12,7 @@ PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核
|
||||
|------|------|------|
|
||||
| 免费 | 交互式全球天气地图 + 城市简报 | 无需登录 |
|
||||
| Pro 试用 | 3 天全功能 | 注册后自动获得 |
|
||||
| Pro 订阅 | 城市决策卡(结构化实况 + 模型证据 + 市场层)、日内分析、历史对账、未来预报 | 10 USDC/月(积分抵扣最多 3 USDC) |
|
||||
| Pro 订阅 | 城市决策卡(结构化实况 + 模型证据 + 市场层)、日内分析、历史对账、未来预报 | 月付 29.9 USDC / 30 天,季度 79.9 USDC / 90 天(月付最多抵 3 USDC,季度最多抵 8 USDC) |
|
||||
|
||||
### 页面结构(9 个路由)
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ export default async function HomePage({
|
||||
price: "29.90",
|
||||
priceCurrency: "USD",
|
||||
description:
|
||||
"Pro subscription for 30 days. Referral users pay 26.90 USD-equivalent USDC for the first month.",
|
||||
"Pro subscription for 30 days. Referral users pay 20.00 USD-equivalent USDC for the first month.",
|
||||
availability: "https://schema.org/InStock",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -12,11 +12,6 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
|
||||
const TELEGRAM_GROUP_URL = String(
|
||||
process.env.NEXT_PUBLIC_TELEGRAM_GROUP_URL ||
|
||||
"https://t.me/+Io5H9oVHFmVjOTQ5",
|
||||
).trim();
|
||||
|
||||
const FAQ_ITEMS = [
|
||||
{
|
||||
q_zh: "Pro 包含哪些功能?",
|
||||
@@ -27,14 +22,14 @@ const FAQ_ITEMS = [
|
||||
{
|
||||
q_zh: "当前订阅价格是多少?",
|
||||
q_en: "What is the current subscription price?",
|
||||
a_zh: "目前仅提供月付:10 USDC / 30 天。",
|
||||
a_en: "Monthly plan only: 10 USDC / 30 days.",
|
||||
a_zh: "Pro 月付 29.9 USDC / 30 天,Pro 季度 79.9 USDC / 90 天。",
|
||||
a_en: "Pro monthly is 29.9 USDC / 30 days. Pro quarterly is 79.9 USDC / 90 days.",
|
||||
},
|
||||
{
|
||||
q_zh: "积分如何抵扣?",
|
||||
q_en: "How do points work for discounts?",
|
||||
a_zh: "满 500 积分起兑,每 500 积分抵 1U,单次最多抵 3U。",
|
||||
a_en: "500 points minimum — every 500 points = 1 USDC off, up to 3 USDC discount per payment.",
|
||||
a_zh: "满 500 积分起兑,每 500 积分抵 1U。月付最多抵 3U,季度最多抵 8U。",
|
||||
a_en: "500 points minimum: every 500 points = 1 USDC off. Monthly orders can use up to 3 USDC off; quarterly orders can use up to 8 USDC off.",
|
||||
},
|
||||
{
|
||||
q_zh: "支持哪些钱包和支付方式?",
|
||||
@@ -55,11 +50,11 @@ export function SubscriptionHelpClient() {
|
||||
? "Complete subscription rules and payment guide."
|
||||
: "这里是完整的订阅规则和支付说明。你可以先在页面内绑定钱包,再直接开通 Pro。",
|
||||
priceLabel: isEn ? "Price" : "订阅价格",
|
||||
priceText: "10 USDC / 30 " + (isEn ? "Days" : "天"),
|
||||
priceText: isEn ? "29.9 / 30d · 79.9 / 90d" : "29.9 / 30天 · 79.9 / 90天",
|
||||
discountLabel: isEn ? "Points Discount" : "积分抵扣",
|
||||
discountText: isEn ? "Up to 3 USDC off" : "最多抵 3U",
|
||||
communityLabel: isEn ? "Community Points" : "社群积分",
|
||||
communityLink: isEn ? "Join community to earn" : "加入社群即可赚取积分",
|
||||
discountText: isEn ? "Monthly 3U · Quarterly 8U" : "月付 3U · 季度 8U",
|
||||
communityLabel: isEn ? "Telegram Group" : "Telegram 群",
|
||||
communityLink: isEn ? "Open Account Center" : "前往账户中心",
|
||||
faqTitle: isEn ? "FAQ" : "常见问题",
|
||||
}), [isEn]);
|
||||
|
||||
@@ -102,8 +97,7 @@ export function SubscriptionHelpClient() {
|
||||
<span className="text-sm font-semibold">{copy.communityLabel}</span>
|
||||
</div>
|
||||
<Link
|
||||
href={TELEGRAM_GROUP_URL}
|
||||
target="_blank"
|
||||
href="/account"
|
||||
className="inline-flex min-h-9 items-center text-sm font-semibold text-blue-700 underline decoration-blue-500/50 underline-offset-4"
|
||||
>
|
||||
{copy.communityLink}
|
||||
|
||||
@@ -431,20 +431,31 @@ export function AccountCenter() {
|
||||
showOverlay,
|
||||
]);
|
||||
|
||||
// ── Weekly points display (component-only derived) ──────
|
||||
const backendWeeklyPointsRaw = Number(backend?.weekly_points);
|
||||
const metadataWeeklyPointsRaw = Number(
|
||||
user?.user_metadata?.weekly_points ?? 0,
|
||||
);
|
||||
const weeklyPointsRaw = Number.isFinite(backendWeeklyPointsRaw)
|
||||
? backendWeeklyPointsRaw
|
||||
: metadataWeeklyPointsRaw;
|
||||
const weeklyRankRaw =
|
||||
backend?.weekly_rank ?? user?.user_metadata?.weekly_rank;
|
||||
const weeklyPoints = Number.isFinite(weeklyPointsRaw)
|
||||
? Math.max(0, weeklyPointsRaw)
|
||||
// ── Referral points display ────────────────────────────
|
||||
const referralRewardPointsRaw = Number(referral?.reward_points ?? 3500);
|
||||
const referralRewardPoints = Number.isFinite(referralRewardPointsRaw)
|
||||
? Math.max(0, referralRewardPointsRaw)
|
||||
: 3500;
|
||||
const monthlyReferralCountRaw = Number(referral?.monthly_reward_count ?? 0);
|
||||
const monthlyReferralCount = Number.isFinite(monthlyReferralCountRaw)
|
||||
? Math.max(0, monthlyReferralCountRaw)
|
||||
: 0;
|
||||
const weeklyRank = weeklyRankRaw == null ? "--" : String(weeklyRankRaw);
|
||||
const monthlyReferralLimitRaw = Number(referral?.monthly_reward_limit ?? 10);
|
||||
const monthlyReferralLimit = Number.isFinite(monthlyReferralLimitRaw)
|
||||
? Math.max(0, monthlyReferralLimitRaw)
|
||||
: 10;
|
||||
const monthlyReferralPointsRaw = Number(
|
||||
referral?.monthly_reward_points ?? monthlyReferralCount * referralRewardPoints,
|
||||
);
|
||||
const monthlyReferralPoints = Number.isFinite(monthlyReferralPointsRaw)
|
||||
? Math.max(0, monthlyReferralPointsRaw)
|
||||
: 0;
|
||||
const monthlyReferralPointsLimitRaw = Number(
|
||||
referral?.monthly_reward_points_limit ?? monthlyReferralLimit * referralRewardPoints,
|
||||
);
|
||||
const monthlyReferralPointsLimit = Number.isFinite(monthlyReferralPointsLimitRaw)
|
||||
? Math.max(0, monthlyReferralPointsLimitRaw)
|
||||
: monthlyReferralLimit * referralRewardPoints;
|
||||
|
||||
// ── Telegram bind command ──────────────────────────────
|
||||
const bindCommand = userId
|
||||
@@ -691,7 +702,7 @@ export function AccountCenter() {
|
||||
</p>
|
||||
<p className="flex items-center justify-center gap-2 text-xl font-bold text-slate-950">
|
||||
<TrendingUp size={16} className="text-emerald-400" />{" "}
|
||||
{weeklyPoints.toLocaleString()}
|
||||
{monthlyReferralCount.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-w-[140px] rounded-xl border border-blue-200 bg-blue-50 px-6 py-4 text-center">
|
||||
@@ -700,13 +711,13 @@ export function AccountCenter() {
|
||||
</p>
|
||||
<p className="flex items-center justify-center gap-2 text-xl font-bold text-slate-950">
|
||||
<Trophy size={16} className="text-amber-400" />{" "}
|
||||
{weeklyRank === "--" ? weeklyRank : `#${weeklyRank}`}
|
||||
{monthlyReferralCount}/{monthlyReferralLimit}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weekly Ranking Motivation */}
|
||||
{/* Referral rewards */}
|
||||
{showSecondarySections ? (
|
||||
<div className="flex flex-col justify-between rounded-2xl border border-slate-200 bg-white p-6 shadow-sm lg:col-span-4">
|
||||
<div>
|
||||
@@ -717,35 +728,29 @@ export function AccountCenter() {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<span className="text-sm flex items-center gap-2">
|
||||
<div className="w-5 h-5 bg-yellow-500 rounded text-black font-bold text-[10px] flex items-center justify-center">
|
||||
1
|
||||
</div>{" "}
|
||||
Top 1
|
||||
<Coins size={16} className="text-yellow-500" />{" "}
|
||||
{copy.referralRewardHint}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-amber-600">
|
||||
+200 积分 & 7天Pro
|
||||
+{referralRewardPoints.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<span className="text-sm flex items-center gap-2">
|
||||
<div className="w-5 h-5 bg-slate-300 rounded text-black font-bold text-[10px] flex items-center justify-center">
|
||||
2
|
||||
</div>{" "}
|
||||
Top 2-3
|
||||
<TrendingUp size={16} className="text-emerald-500" />{" "}
|
||||
{copy.weeklyPoints}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-slate-600">
|
||||
+100 积分
|
||||
{monthlyReferralCount}/{monthlyReferralLimit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<span className="text-sm flex items-center gap-2">
|
||||
<div className="w-5 h-5 bg-orange-800 rounded text-white font-bold text-[10px] flex items-center justify-center">
|
||||
4
|
||||
</div>{" "}
|
||||
Top 4-10
|
||||
<Trophy size={16} className="text-blue-500" />{" "}
|
||||
{copy.totalPoints}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-orange-400">
|
||||
+50 积分
|
||||
{monthlyReferralPoints.toLocaleString()}/{monthlyReferralPointsLimit.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -753,8 +758,7 @@ export function AccountCenter() {
|
||||
<div className="mt-6 flex items-start gap-2 rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<Info size={14} className="text-slate-500 mt-0.5 shrink-0" />
|
||||
<p className="text-[10px] text-slate-500 leading-normal italic">
|
||||
积分规则:群内有效发言(自动防刷检测)+
|
||||
每日首条发言额外奖励。每周一零点结算周榜,所有活跃用户均享参与奖。
|
||||
{copy.pointsRule}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,14 @@ export function runTests() {
|
||||
accountCenter.includes("29.9"),
|
||||
"account center must show monthly and quarterly Pro prices",
|
||||
);
|
||||
assert(
|
||||
accountCopy.includes("20 USDC") &&
|
||||
accountCopy.includes("+3500 积分") &&
|
||||
accountCopy.includes("月付订单最多抵扣 3 USDC") &&
|
||||
accountCopy.includes("季度订单最多抵扣 8 USDC") &&
|
||||
!accountCopy.includes("群内有效发言"),
|
||||
"account copy must describe balanced referral points and remove group-message points",
|
||||
);
|
||||
assert(
|
||||
!useAccountPayment.includes("monthlyPlanList") &&
|
||||
!usePaymentFlow.includes("monthlyPlanList"),
|
||||
@@ -54,7 +62,8 @@ export function runTests() {
|
||||
assert(
|
||||
types.includes("ReferralSummary") &&
|
||||
types.includes("referral?: ReferralSummary | null") &&
|
||||
types.includes("duration_days: number"),
|
||||
types.includes("duration_days: number") &&
|
||||
types.includes("max_discount_usdc_by_plan"),
|
||||
"account auth and payment types must include referral summary and plan durations",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
|
||||
guestUser: isEn ? "Signed-out account" : "未登录账户",
|
||||
joinedAt: isEn ? "Joined" : "加入时间",
|
||||
totalPoints: isEn ? "Total Points" : "总积分 (荣誉)",
|
||||
weeklyPoints: isEn ? "Weekly Points" : "本周积分 (竞技)",
|
||||
weeklyRank: isEn ? "Weekly Rank" : "周排行 (竞技)",
|
||||
weeklyRewards: isEn ? "Weekly Rewards" : "周榜奖励",
|
||||
weeklyPoints: isEn ? "This Month Invites" : "本月有效邀请",
|
||||
weeklyRank: isEn ? "Monthly Cap" : "月度上限",
|
||||
weeklyRewards: isEn ? "Referral Rewards" : "邀请奖励",
|
||||
membershipDetails: isEn ? "Membership Details" : "会员权限详情",
|
||||
identityStatus: isEn ? "Identity Status" : "身份状态",
|
||||
authMode: isEn ? "Auth Mode" : "鉴权模式",
|
||||
@@ -74,14 +74,14 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
|
||||
: "邀请码已应用,首月 Pro 将按邀请价结算。",
|
||||
referralApplyFailed: isEn ? "Failed to apply referral code" : "邀请码应用失败",
|
||||
referralDiscountHint: isEn
|
||||
? "Invite discount: first monthly Pro is 26.9 USDC."
|
||||
: "邀请首月价:Pro 月付 26.9 USDC。",
|
||||
? "Invite discount: first monthly Pro is 20 USDC."
|
||||
: "邀请首月价:Pro 月付 20 USDC。",
|
||||
referralRewardHint: isEn
|
||||
? "When an invited user pays for Pro, you receive +3 days Pro."
|
||||
: "被邀请人成功付费后,邀请人获得 +3 天 Pro。",
|
||||
? "When an invited user pays for Pro, you receive +3500 points."
|
||||
: "被邀请人成功付费后,邀请人获得 +3500 积分。",
|
||||
referralInviteLimit: isEn
|
||||
? "Monthly referral reward cap: 10 paid invites, up to +30 days Pro."
|
||||
: "每月最多 10 个有效付费邀请奖励,最高 +30 天 Pro。",
|
||||
? "Monthly referral reward cap: 10 paid invites, up to +35000 points."
|
||||
: "每月最多 10 个有效付费邀请奖励,最高 +35000 积分。",
|
||||
paymentToken: isEn ? "Payment Token" : "支付币种",
|
||||
paymentAccount: isEn ? "Subscription Account" : "订阅归属账号",
|
||||
paymentWallet: isEn ? "Paying Wallet" : "付款钱包",
|
||||
@@ -334,8 +334,8 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
|
||||
: "加载支付配置失败: {raw}",
|
||||
// ── Points / Footer ────────────────────────────────────────────────
|
||||
pointsRule: isEn
|
||||
? "Points rule: active messages in group (anti-spam) + daily first message bonus. Weekly leaderboard settles every Monday midnight. All active members receive participation awards."
|
||||
: "积分规则:群内有效发言(自动防刷检测)+ 每日首条发言额外奖励。每周一零点结算周榜,所有活跃用户均享参与奖。",
|
||||
? "Points rule: points are earned through successful paid referrals only. 500 points = 1 USDC. Monthly orders can use up to 3 USDC off; quarterly orders can use up to 8 USDC off."
|
||||
: "积分规则:积分仅通过有效付费邀请获得。500 积分 = 1 USDC。月付订单最多抵扣 3 USDC,季度订单最多抵扣 8 USDC。",
|
||||
pointsDiscount: isEn
|
||||
? "Available: {points} points discounting ${amount}. Applied automatically on renewal."
|
||||
: "当前可用 {points} 积分抵扣 ${amount},续费时会自动生效。",
|
||||
|
||||
@@ -26,10 +26,13 @@ export type ReferralSummary = {
|
||||
discount_usdc?: string;
|
||||
discounted_monthly_amount_usdc?: string;
|
||||
reward_days?: number;
|
||||
reward_points?: number;
|
||||
monthly_reward_limit?: number;
|
||||
monthly_reward_days_limit?: number;
|
||||
monthly_reward_points_limit?: number;
|
||||
monthly_reward_count?: number;
|
||||
monthly_reward_days?: number;
|
||||
monthly_reward_points?: number;
|
||||
applied_code?: string;
|
||||
attribution_status?: string;
|
||||
};
|
||||
@@ -81,6 +84,7 @@ export type PointsRedemptionConfig = {
|
||||
enabled?: boolean;
|
||||
points_per_usdc?: number;
|
||||
max_discount_usdc?: number;
|
||||
max_discount_usdc_by_plan?: Record<string, number>;
|
||||
};
|
||||
|
||||
export type PaymentConfig = {
|
||||
|
||||
@@ -150,7 +150,10 @@ export function useBilling(params: UseBillingParams) {
|
||||
const pointsPerUsdc =
|
||||
Number.isFinite(pointsPerUsdcRaw) && pointsPerUsdcRaw > 0 ? Math.floor(pointsPerUsdcRaw) : 500;
|
||||
|
||||
const maxDiscountRaw = Number(pointsCfg.max_discount_usdc ?? 3);
|
||||
const maxDiscountByPlan = pointsCfg.max_discount_usdc_by_plan || {};
|
||||
const planMaxDiscountRaw =
|
||||
maxDiscountByPlan[selectedPlanCode] ?? pointsCfg.max_discount_usdc ?? 3;
|
||||
const maxDiscountRaw = Number(planMaxDiscountRaw);
|
||||
const maxDiscountUsdc = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
@@ -160,10 +163,11 @@ export function useBilling(params: UseBillingParams) {
|
||||
);
|
||||
|
||||
const maxRedeemablePoints = pointsPerUsdc * maxDiscountUsdc;
|
||||
const actualRedeem = pointsEnabled ? Math.min(totalPoints, maxRedeemablePoints) : 0;
|
||||
const pointsCanApply = pointsEnabled && !referralApplies;
|
||||
const actualRedeem = pointsCanApply ? Math.min(totalPoints, maxRedeemablePoints) : 0;
|
||||
const discountUnits = Math.floor(actualRedeem / pointsPerUsdc);
|
||||
const pointsUsed = discountUnits * pointsPerUsdc;
|
||||
const canRedeem = pointsEnabled && maxDiscountUsdc > 0 && totalPoints >= pointsPerUsdc;
|
||||
const canRedeem = pointsCanApply && maxDiscountUsdc > 0 && totalPoints >= pointsPerUsdc;
|
||||
const applyDiscount = usePoints && canRedeem && pointsUsed > 0;
|
||||
|
||||
return {
|
||||
|
||||
@@ -131,13 +131,13 @@ function InstitutionalLandingScreen() {
|
||||
{ label: "Trial", value: "3 days" },
|
||||
{ label: "Monthly", value: "29.9 USDC" },
|
||||
{ label: "Quarterly", value: "79.9 USDC" },
|
||||
{ label: "Referral", value: "26.9 USDC" },
|
||||
{ label: "Referral", value: "20 USDC" },
|
||||
]
|
||||
: [
|
||||
{ label: "试用", value: "3 天" },
|
||||
{ label: "月付", value: "29.9 USDC" },
|
||||
{ label: "季度", value: "79.9 USDC" },
|
||||
{ label: "邀请首月", value: "26.9 USDC" },
|
||||
{ label: "邀请首月", value: "20 USDC" },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -387,7 +387,7 @@ function InstitutionalLandingScreen() {
|
||||
<span className="text-sm font-semibold text-slate-500">USDC / 30 天</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs font-semibold text-slate-500">
|
||||
{isEn ? "Referral first month: 26.9 USDC" : "使用邀请码首月 26.9 USDC"}
|
||||
{isEn ? "Referral first month: 20 USDC" : "使用邀请码首月 20 USDC"}
|
||||
</p>
|
||||
<ul className="mt-7 space-y-3 border-t border-slate-200 pt-6">
|
||||
{(isEn ? PRO_FEATURES_EN : PRO_FEATURES_ZH).map((feature) => (
|
||||
@@ -425,8 +425,8 @@ function InstitutionalLandingScreen() {
|
||||
</div>
|
||||
<div className="mt-5 rounded-md border border-slate-200 bg-white px-4 py-3 text-xs font-semibold text-slate-600">
|
||||
{isEn
|
||||
? "Invite reward: referrer gets +3 days Pro when invitee subscribes."
|
||||
: "邀请奖励:被邀请人付费后,邀请人 +3 天 Pro。"}
|
||||
? "Invite reward: referrer gets +3500 points when invitee subscribes."
|
||||
: "邀请奖励:被邀请人付费后,邀请人 +3500 积分。"}
|
||||
</div>
|
||||
<Link
|
||||
href="/account"
|
||||
|
||||
@@ -20,7 +20,7 @@ export function runTests() {
|
||||
assert(source.includes("/static/web.png"), "landing page must show the product preview image");
|
||||
assert(source.includes("29.9") && source.includes("30 天"), "landing page must show monthly Pro pricing");
|
||||
assert(source.includes("79.9") && source.includes("90 天"), "landing page must show quarterly Pro pricing");
|
||||
assert(source.includes("26.9") && source.includes("+3 天 Pro"), "landing page must describe referral discount and reward");
|
||||
assert(source.includes("20 USDC") && source.includes("+3500 积分"), "landing page must describe referral discount and reward");
|
||||
assert(!source.includes("AI 气象证据链解读"), "legacy AI evidence-chain wording must be removed");
|
||||
assert(!source.includes("AI weather evidence"), "legacy AI evidence wording must be removed");
|
||||
assert(!source.includes("$10"), "legacy $10/month pricing must be removed from landing page");
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import s from "./UnlockProOverlay.module.css";
|
||||
|
||||
const DEFAULT_FAQ_HREF = "/subscription-help";
|
||||
const DEFAULT_TELEGRAM_GROUP_URL = "https://t.me/+Io5H9oVHFmVjOTQ5";
|
||||
const DEFAULT_TELEGRAM_GROUP_URL = "";
|
||||
|
||||
export type UnlockProBilling = {
|
||||
pointsEnabled: boolean;
|
||||
@@ -310,16 +310,16 @@ export function UnlockProOverlay({
|
||||
>
|
||||
<MessageSquare size={12} />
|
||||
{isEn
|
||||
? "Join community to earn points"
|
||||
: "加入社群即可赚取积分"}
|
||||
? "Invite paid users to earn points"
|
||||
: "邀请付费用户即可获得积分"}
|
||||
<ArrowRight size={11} />
|
||||
</Link>
|
||||
) : (
|
||||
<span className={s.unavailCta} style={{ cursor: "default" }}>
|
||||
<MessageSquare size={12} />
|
||||
{isEn
|
||||
? "Join community to earn points"
|
||||
: "加入社群即可赚取积分"}
|
||||
? "Invite paid users to earn points"
|
||||
: "邀请付费用户即可获得积分"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
-- PolyWeather balanced referral points program.
|
||||
-- Run once before deploying referral point rewards.
|
||||
|
||||
alter table public.referral_rewards
|
||||
add column if not exists reward_points integer not null default 0;
|
||||
|
||||
alter table public.referral_rewards
|
||||
alter column reward_days set default 0;
|
||||
|
||||
alter table public.referral_rewards
|
||||
drop constraint if exists referral_rewards_reward_days_check;
|
||||
|
||||
alter table public.referral_rewards
|
||||
add constraint referral_rewards_reward_days_check
|
||||
check (reward_days >= 0 and reward_days <= 30);
|
||||
|
||||
alter table public.referral_rewards
|
||||
drop constraint if exists referral_rewards_reward_points_check;
|
||||
|
||||
alter table public.referral_rewards
|
||||
add constraint referral_rewards_reward_points_check
|
||||
check (reward_points >= 0 and reward_points <= 100000);
|
||||
|
||||
create index if not exists idx_referral_rewards_referrer_month_points
|
||||
on public.referral_rewards(referrer_user_id, created_at desc)
|
||||
include (id, reward_points);
|
||||
|
||||
create table if not exists public.points_ledger (
|
||||
id bigserial primary key,
|
||||
user_id uuid not null references auth.users(id) on delete cascade,
|
||||
delta integer not null check (delta <> 0),
|
||||
source text not null,
|
||||
reason text not null,
|
||||
payment_intent_id text,
|
||||
referral_attribution_id bigint references public.referral_attributions(id) on delete set null,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_points_ledger_user_created
|
||||
on public.points_ledger(user_id, created_at desc);
|
||||
|
||||
create index if not exists idx_points_ledger_referral
|
||||
on public.points_ledger(referral_attribution_id)
|
||||
where referral_attribution_id is not null;
|
||||
|
||||
alter table public.points_ledger enable row level security;
|
||||
|
||||
drop policy if exists points_ledger_select_own on public.points_ledger;
|
||||
create policy points_ledger_select_own
|
||||
on public.points_ledger
|
||||
for select
|
||||
to authenticated
|
||||
using (auth.uid() = user_id);
|
||||
|
||||
analyze public.referral_rewards;
|
||||
analyze public.points_ledger;
|
||||
@@ -0,0 +1,94 @@
|
||||
-- Verify the balanced referral-points migration.
|
||||
-- Run after scripts/supabase/referral_points_rewards_20260530.sql.
|
||||
|
||||
with checks(check_name, ok) as (
|
||||
values
|
||||
(
|
||||
'referral_rewards.reward_points column',
|
||||
exists (
|
||||
select 1
|
||||
from information_schema.columns
|
||||
where table_schema = 'public'
|
||||
and table_name = 'referral_rewards'
|
||||
and column_name = 'reward_points'
|
||||
and is_nullable = 'NO'
|
||||
)
|
||||
),
|
||||
(
|
||||
'referral_rewards.reward_points check',
|
||||
exists (
|
||||
select 1
|
||||
from pg_constraint c
|
||||
join pg_class t on t.oid = c.conrelid
|
||||
join pg_namespace n on n.oid = t.relnamespace
|
||||
where n.nspname = 'public'
|
||||
and t.relname = 'referral_rewards'
|
||||
and c.conname = 'referral_rewards_reward_points_check'
|
||||
)
|
||||
),
|
||||
(
|
||||
'referral_rewards monthly reward index includes points',
|
||||
exists (
|
||||
select 1
|
||||
from pg_indexes
|
||||
where schemaname = 'public'
|
||||
and tablename = 'referral_rewards'
|
||||
and indexname = 'idx_referral_rewards_referrer_month'
|
||||
and indexdef ilike '%reward_points%'
|
||||
)
|
||||
),
|
||||
(
|
||||
'points_ledger table',
|
||||
exists (
|
||||
select 1
|
||||
from information_schema.tables
|
||||
where table_schema = 'public'
|
||||
and table_name = 'points_ledger'
|
||||
)
|
||||
),
|
||||
(
|
||||
'points_ledger user index',
|
||||
exists (
|
||||
select 1
|
||||
from pg_indexes
|
||||
where schemaname = 'public'
|
||||
and tablename = 'points_ledger'
|
||||
and indexname = 'idx_points_ledger_user_created'
|
||||
)
|
||||
),
|
||||
(
|
||||
'points_ledger referral index',
|
||||
exists (
|
||||
select 1
|
||||
from pg_indexes
|
||||
where schemaname = 'public'
|
||||
and tablename = 'points_ledger'
|
||||
and indexname = 'idx_points_ledger_referral'
|
||||
)
|
||||
),
|
||||
(
|
||||
'points_ledger RLS enabled',
|
||||
exists (
|
||||
select 1
|
||||
from pg_class t
|
||||
join pg_namespace n on n.oid = t.relnamespace
|
||||
where n.nspname = 'public'
|
||||
and t.relname = 'points_ledger'
|
||||
and t.relrowsecurity
|
||||
)
|
||||
),
|
||||
(
|
||||
'points_ledger own-row select policy',
|
||||
exists (
|
||||
select 1
|
||||
from pg_policies
|
||||
where schemaname = 'public'
|
||||
and tablename = 'points_ledger'
|
||||
and policyname = 'points_ledger_select_own'
|
||||
and cmd = 'SELECT'
|
||||
)
|
||||
)
|
||||
)
|
||||
select check_name, ok
|
||||
from checks
|
||||
order by check_name;
|
||||
@@ -392,7 +392,8 @@ create table if not exists public.referral_rewards (
|
||||
referred_user_id uuid not null references auth.users(id) on delete cascade,
|
||||
payment_intent_id text not null,
|
||||
tx_hash text,
|
||||
reward_days integer not null default 3 check (reward_days > 0 and reward_days <= 30),
|
||||
reward_days integer not null default 0 check (reward_days >= 0 and reward_days <= 30),
|
||||
reward_points integer not null default 0 check (reward_points >= 0 and reward_points <= 100000),
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
@@ -401,7 +402,35 @@ create unique index if not exists uq_referral_rewards_attribution
|
||||
|
||||
create index if not exists idx_referral_rewards_referrer_month
|
||||
on public.referral_rewards(referrer_user_id, created_at desc)
|
||||
include (id, reward_days);
|
||||
include (id, reward_days, reward_points);
|
||||
|
||||
create table if not exists public.points_ledger (
|
||||
id bigserial primary key,
|
||||
user_id uuid not null references auth.users(id) on delete cascade,
|
||||
delta integer not null check (delta <> 0),
|
||||
source text not null,
|
||||
reason text not null,
|
||||
payment_intent_id text,
|
||||
referral_attribution_id bigint references public.referral_attributions(id) on delete set null,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_points_ledger_user_created
|
||||
on public.points_ledger(user_id, created_at desc);
|
||||
|
||||
create index if not exists idx_points_ledger_referral
|
||||
on public.points_ledger(referral_attribution_id)
|
||||
where referral_attribution_id is not null;
|
||||
|
||||
alter table public.points_ledger enable row level security;
|
||||
|
||||
drop policy if exists points_ledger_select_own on public.points_ledger;
|
||||
create policy points_ledger_select_own
|
||||
on public.points_ledger
|
||||
for select
|
||||
to authenticated
|
||||
using (auth.uid() = user_id);
|
||||
|
||||
create table if not exists public.wallet_link_challenges (
|
||||
id bigserial primary key,
|
||||
|
||||
@@ -77,7 +77,8 @@ create table if not exists public.referral_rewards (
|
||||
referred_user_id uuid not null references auth.users(id) on delete cascade,
|
||||
payment_intent_id text not null,
|
||||
tx_hash text,
|
||||
reward_days integer not null default 3 check (reward_days > 0 and reward_days <= 30),
|
||||
reward_days integer not null default 0 check (reward_days >= 0 and reward_days <= 30),
|
||||
reward_points integer not null default 0 check (reward_points >= 0 and reward_points <= 100000),
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
@@ -86,7 +87,7 @@ create unique index if not exists uq_referral_rewards_attribution
|
||||
|
||||
create index if not exists idx_referral_rewards_referrer_month
|
||||
on public.referral_rewards(referrer_user_id, created_at desc)
|
||||
include (id, reward_days);
|
||||
include (id, reward_days, reward_points);
|
||||
|
||||
analyze public.trial_claims;
|
||||
analyze public.trial_claim_wallets;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -27,7 +27,7 @@ def _message(chat_id: int | str, text: str = "有效发言"):
|
||||
)
|
||||
|
||||
|
||||
def test_group_message_points_include_configured_forum_chat(monkeypatch):
|
||||
def test_group_message_points_disabled_by_default(monkeypatch):
|
||||
monkeypatch.delenv("POLYWEATHER_BOT_POINTS_CHAT_IDS", raising=False)
|
||||
monkeypatch.delenv("POLYWEATHER_BOT_POINTS_CHAT_ID", raising=False)
|
||||
monkeypatch.setenv("TELEGRAM_CHAT_IDS", "-1003965137823")
|
||||
@@ -37,9 +37,8 @@ def test_group_message_points_include_configured_forum_chat(monkeypatch):
|
||||
|
||||
io_layer.track_group_text_activity(_message(-1003965137823))
|
||||
|
||||
assert db.upserts == [(123, "alice")]
|
||||
assert len(db.activities) == 1
|
||||
assert db.activities[0]["telegram_id"] == 123
|
||||
assert db.upserts == []
|
||||
assert db.activities == []
|
||||
|
||||
|
||||
def test_group_message_points_skip_unconfigured_chat_when_allowlist_exists(monkeypatch):
|
||||
|
||||
@@ -32,6 +32,63 @@ def test_default_payment_catalog_has_monthly_and_quarterly_prices(monkeypatch, t
|
||||
assert plans["pro_quarterly"]["amount_usdc"] == "79.9"
|
||||
assert plans["pro_quarterly"]["duration_days"] == 90
|
||||
|
||||
points_config = service.get_config_payload()["points_redemption"]
|
||||
assert points_config["points_per_usdc"] == 500
|
||||
assert points_config["max_discount_usdc_by_plan"]["pro_monthly"] == 3
|
||||
assert points_config["max_discount_usdc_by_plan"]["pro_quarterly"] == 8
|
||||
|
||||
|
||||
def test_points_redemption_uses_plan_specific_caps(monkeypatch, tmp_path):
|
||||
_payment_env(monkeypatch, tmp_path)
|
||||
service = PaymentContractCheckoutService()
|
||||
intent_posts = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_get_pending_referral_attribution",
|
||||
lambda user_id: None,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_resolve_points_balance",
|
||||
lambda user_id: {
|
||||
"source": "supabase_metadata",
|
||||
"balance": 5000,
|
||||
"metadata": {"points": 5000},
|
||||
},
|
||||
raising=False,
|
||||
)
|
||||
|
||||
def fake_rest(method, table, **kwargs):
|
||||
if method == "POST" and table == "payment_intents":
|
||||
intent_posts.append(kwargs["payload"])
|
||||
return []
|
||||
raise AssertionError((method, table, kwargs))
|
||||
|
||||
monkeypatch.setattr(service, "_rest", fake_rest)
|
||||
|
||||
monthly = service.create_intent(
|
||||
user_id="user-1",
|
||||
plan_code="pro_monthly",
|
||||
payment_mode="direct",
|
||||
use_points=True,
|
||||
)
|
||||
quarterly = service.create_intent(
|
||||
user_id="user-1",
|
||||
plan_code="pro_quarterly",
|
||||
payment_mode="direct",
|
||||
use_points=True,
|
||||
)
|
||||
|
||||
assert monthly["plan"]["amount_after_discount_usdc"] == "26.9"
|
||||
assert intent_posts[0]["metadata"]["points_redemption"]["points_to_consume"] == 1500
|
||||
assert intent_posts[0]["metadata"]["points_redemption"]["max_discount_usdc"] == 3
|
||||
|
||||
assert quarterly["plan"]["amount_after_discount_usdc"] == "71.9"
|
||||
assert intent_posts[1]["metadata"]["points_redemption"]["points_to_consume"] == 4000
|
||||
assert intent_posts[1]["metadata"]["points_redemption"]["max_discount_usdc"] == 8
|
||||
|
||||
|
||||
def test_paid_subscription_replaces_active_signup_trial_immediately(monkeypatch, tmp_path):
|
||||
_payment_env(monkeypatch, tmp_path)
|
||||
@@ -187,9 +244,116 @@ def test_referral_discount_applies_to_first_monthly_payment(monkeypatch, tmp_pat
|
||||
)
|
||||
|
||||
assert result["plan"]["amount_before_discount_usdc"] == "29.9"
|
||||
assert result["plan"]["amount_after_discount_usdc"] == "26.9"
|
||||
assert intent_posts[0]["amount_units"] == "26900000"
|
||||
assert intent_posts[0]["metadata"]["referral_discount"]["discount_usdc"] == "3"
|
||||
assert result["plan"]["amount_after_discount_usdc"] == "20"
|
||||
assert intent_posts[0]["amount_units"] == "20000000"
|
||||
assert intent_posts[0]["metadata"]["referral_discount"]["discount_usdc"] == "9.9"
|
||||
|
||||
|
||||
def test_referral_first_month_price_does_not_stack_with_points(monkeypatch, tmp_path):
|
||||
_payment_env(monkeypatch, tmp_path)
|
||||
service = PaymentContractCheckoutService()
|
||||
intent_posts = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_get_pending_referral_attribution",
|
||||
lambda user_id: {
|
||||
"id": 7,
|
||||
"code": "YUAN2026",
|
||||
"referrer_user_id": "referrer-1",
|
||||
},
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_has_prior_paid_subscription",
|
||||
lambda user_id: False,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_resolve_points_balance",
|
||||
lambda user_id: {
|
||||
"source": "supabase_metadata",
|
||||
"balance": 5000,
|
||||
"metadata": {"points": 5000},
|
||||
},
|
||||
raising=False,
|
||||
)
|
||||
|
||||
def fake_rest(method, table, **kwargs):
|
||||
if method == "POST" and table == "payment_intents":
|
||||
intent_posts.append(kwargs["payload"])
|
||||
return []
|
||||
raise AssertionError((method, table, kwargs))
|
||||
|
||||
monkeypatch.setattr(service, "_rest", fake_rest)
|
||||
|
||||
result = service.create_intent(
|
||||
user_id="referred-1",
|
||||
plan_code="pro_monthly",
|
||||
payment_mode="direct",
|
||||
use_points=True,
|
||||
points_to_consume=1500,
|
||||
)
|
||||
|
||||
assert result["plan"]["amount_after_discount_usdc"] == "20"
|
||||
assert intent_posts[0]["amount_units"] == "20000000"
|
||||
assert intent_posts[0]["metadata"]["points_redemption"]["applied"] is False
|
||||
assert intent_posts[0]["metadata"]["points_redemption"]["points_to_consume"] == 0
|
||||
|
||||
|
||||
def test_referral_reward_grants_points_instead_of_pro_days(monkeypatch):
|
||||
monkeypatch.setenv("POLYWEATHER_AUTH_ENABLED", "true")
|
||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon")
|
||||
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||
service = SupabaseEntitlementService()
|
||||
writes = []
|
||||
point_grants = []
|
||||
|
||||
def fake_rest(method, table, **kwargs):
|
||||
if method == "GET" and table == "referral_attributions":
|
||||
return [
|
||||
{
|
||||
"id": 77,
|
||||
"referrer_user_id": "referrer-1",
|
||||
"referred_user_id": "referred-1",
|
||||
"status": "pending",
|
||||
}
|
||||
]
|
||||
if method == "GET" and table == "referral_rewards":
|
||||
return []
|
||||
if method in {"POST", "PATCH"}:
|
||||
writes.append({"method": method, "table": table, **kwargs})
|
||||
return []
|
||||
raise AssertionError((method, table, kwargs))
|
||||
|
||||
monkeypatch.setattr(service, "_rest", fake_rest)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_grant_referral_points",
|
||||
lambda referrer_user_id, points: point_grants.append(
|
||||
{"referrer_user_id": referrer_user_id, "points": points}
|
||||
)
|
||||
or {"ok": True, "points_added": points, "points_after": points},
|
||||
raising=False,
|
||||
)
|
||||
|
||||
result = service.settle_referral_reward(
|
||||
referred_user_id="referred-1",
|
||||
payment_intent_id="intent-1",
|
||||
tx_hash="0x" + "2" * 64,
|
||||
)
|
||||
|
||||
assert result["awarded"] is True
|
||||
assert result["reward_points"] == 3500
|
||||
assert result["reward_days"] == 0
|
||||
assert point_grants == [{"referrer_user_id": "referrer-1", "points": 3500}]
|
||||
assert not any(call["table"] == "subscriptions" for call in writes)
|
||||
reward_write = next(call for call in writes if call["table"] == "referral_rewards")
|
||||
assert reward_write["payload"]["reward_points"] == 3500
|
||||
assert reward_write["payload"]["reward_days"] == 0
|
||||
|
||||
|
||||
def test_referral_reward_respects_monthly_ten_invite_cap(monkeypatch):
|
||||
@@ -211,7 +375,7 @@ def test_referral_reward_respects_monthly_ten_invite_cap(monkeypatch):
|
||||
}
|
||||
]
|
||||
if method == "GET" and table == "referral_rewards":
|
||||
return [{"id": i} for i in range(10)]
|
||||
return [{"id": i, "reward_points": 3500} for i in range(10)]
|
||||
if method in {"POST", "PATCH"}:
|
||||
writes.append({"method": method, "table": table, **kwargs})
|
||||
return []
|
||||
@@ -227,7 +391,56 @@ def test_referral_reward_respects_monthly_ten_invite_cap(monkeypatch):
|
||||
|
||||
assert result["awarded"] is False
|
||||
assert result["reason"] == "monthly_cap_reached"
|
||||
assert not any(call["table"] == "subscriptions" for call in writes)
|
||||
assert not any(call["table"] == "referral_rewards" for call in writes)
|
||||
|
||||
|
||||
def test_referral_reward_existing_reward_row_does_not_grant_points(monkeypatch):
|
||||
monkeypatch.setenv("POLYWEATHER_AUTH_ENABLED", "true")
|
||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon")
|
||||
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||
service = SupabaseEntitlementService()
|
||||
point_grants = []
|
||||
|
||||
def fake_rest(method, table, **kwargs):
|
||||
if method == "GET" and table == "referral_attributions":
|
||||
return [
|
||||
{
|
||||
"id": 77,
|
||||
"referrer_user_id": "referrer-1",
|
||||
"referred_user_id": "referred-1",
|
||||
"status": "pending",
|
||||
}
|
||||
]
|
||||
if method == "GET" and table == "referral_rewards":
|
||||
params = kwargs.get("params") or {}
|
||||
if params.get("referral_attribution_id") == "eq.77":
|
||||
return [{"id": 88, "reward_points": 3500}]
|
||||
return []
|
||||
if method in {"POST", "PATCH"}:
|
||||
return []
|
||||
raise AssertionError((method, table, kwargs))
|
||||
|
||||
monkeypatch.setattr(service, "_rest", fake_rest)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_grant_referral_points",
|
||||
lambda referrer_user_id, points: point_grants.append(
|
||||
{"referrer_user_id": referrer_user_id, "points": points}
|
||||
)
|
||||
or {"ok": True, "points_added": points, "points_after": points},
|
||||
raising=False,
|
||||
)
|
||||
|
||||
result = service.settle_referral_reward(
|
||||
referred_user_id="referred-1",
|
||||
payment_intent_id="intent-1",
|
||||
tx_hash="0x" + "2" * 64,
|
||||
)
|
||||
|
||||
assert result["awarded"] is False
|
||||
assert result["reason"] == "already_rewarded"
|
||||
assert point_grants == []
|
||||
|
||||
|
||||
def test_signup_trial_falls_back_to_entitlement_events_without_trial_tables(monkeypatch):
|
||||
@@ -333,6 +546,7 @@ def test_referral_reward_falls_back_to_entitlement_events_without_referral_table
|
||||
}
|
||||
]
|
||||
writes = []
|
||||
point_grants = []
|
||||
|
||||
def fake_rest(method, table, **kwargs):
|
||||
if table in {"referral_attributions", "referral_rewards"}:
|
||||
@@ -341,17 +555,23 @@ def test_referral_reward_falls_back_to_entitlement_events_without_referral_table
|
||||
user_filter = str((kwargs.get("params") or {}).get("user_id") or "")
|
||||
user_id = user_filter[3:] if user_filter.startswith("eq.") else user_filter
|
||||
return [event for event in events if event.get("user_id") == user_id]
|
||||
if method == "GET" and table == "subscriptions":
|
||||
return []
|
||||
if method == "POST" and table in {"subscriptions", "entitlement_events"}:
|
||||
if method == "POST" and table == "entitlement_events":
|
||||
payload = kwargs.get("payload") or {}
|
||||
writes.append({"table": table, "payload": payload})
|
||||
if table == "entitlement_events":
|
||||
events.append(payload)
|
||||
events.append(payload)
|
||||
return [payload]
|
||||
raise AssertionError((method, table, kwargs))
|
||||
|
||||
monkeypatch.setattr(service, "_rest", fake_rest)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_grant_referral_points",
|
||||
lambda referrer_user_id, points: point_grants.append(
|
||||
{"referrer_user_id": referrer_user_id, "points": points}
|
||||
)
|
||||
or {"ok": True, "points_added": points, "points_after": points},
|
||||
raising=False,
|
||||
)
|
||||
|
||||
result = service.settle_referral_reward(
|
||||
referred_user_id=referred_id,
|
||||
@@ -360,9 +580,12 @@ def test_referral_reward_falls_back_to_entitlement_events_without_referral_table
|
||||
)
|
||||
|
||||
assert result["awarded"] is True
|
||||
assert any(call["table"] == "subscriptions" for call in writes)
|
||||
assert result["reward_points"] == 3500
|
||||
assert not any(call["table"] == "subscriptions" for call in writes)
|
||||
assert point_grants == [{"referrer_user_id": referrer_id, "points": 3500}]
|
||||
assert any(
|
||||
call["table"] == "entitlement_events"
|
||||
and call["payload"]["action"] == "referral_reward_granted"
|
||||
and call["payload"]["payload"]["reward_points"] == 3500
|
||||
for call in writes
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@ class _User:
|
||||
self.first_name = username
|
||||
|
||||
|
||||
def test_weekly_points_display_uses_weekly_profile_archive_fallback(tmp_path):
|
||||
def test_points_rank_display_uses_invite_points_copy(tmp_path):
|
||||
db = DBManager(str(tmp_path / "test.db"))
|
||||
io = BotIOLayer(_DummyBot(), db)
|
||||
user = _User(1001, "yuan")
|
||||
@@ -46,6 +46,8 @@ def test_weekly_points_display_uses_weekly_profile_archive_fallback(tmp_path):
|
||||
conn.commit()
|
||||
|
||||
text = io.build_points_rank_text(user)
|
||||
assert "累计发言" in text
|
||||
assert "本周发言积分: <code>192</code>" in text
|
||||
assert "本周排名: <code>1/1</code>" in text
|
||||
assert "用户积分排行" in text
|
||||
assert "累计积分: <code>206</code>" in text
|
||||
assert "积分获取: <code>邀请付费用户</code>" in text
|
||||
assert "本周发言积分" not in text
|
||||
assert "今日发言积分" not in text
|
||||
|
||||
Reference in New Issue
Block a user