feat: revamp points/reward system for inclusive engagement

- /city /deb now free, capped at 10/day each (was 2 pts cost)
- Welcome bonus +20 pts on first-ever valid message
- First-message-of-day bonus +2 pts
- Weekly winner point bonuses reduced (500→200, 300→100, 150→50)
- Weekly participation rewards for all active users (+5 base, +15 for ≥20 pts)
- Pro-day rewards for top 3 unchanged

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
AmandaloveYang
2026-05-11 13:35:52 +08:00
parent 81e9aeb98f
commit 787d04619b
8 changed files with 215 additions and 27 deletions
@@ -2602,7 +2602,7 @@ export function AccountCenter() {
Top 1
</span>
<span className="text-xs font-bold text-yellow-500">
+500 & 7Pro
+200 & 7Pro
</span>
</div>
<div className="flex items-center justify-between p-3 bg-white/5 rounded-xl border border-white/5">
@@ -2613,7 +2613,7 @@ export function AccountCenter() {
Top 2-3
</span>
<span className="text-xs font-bold text-slate-300">
+300 & 3Pro
+100 & 3Pro
</span>
</div>
<div className="flex items-center justify-between p-3 bg-white/5 rounded-xl border border-white/5">
@@ -2624,7 +2624,7 @@ export function AccountCenter() {
Top 4-10
</span>
<span className="text-xs font-bold text-orange-400">
+150
+50
</span>
</div>
</div>
@@ -2632,7 +2632,7 @@ export function AccountCenter() {
<div className="mt-6 flex items-start gap-2 p-3 bg-black/20 rounded-xl">
<Info size={14} className="text-slate-500 mt-0.5 shrink-0" />
<p className="text-[10px] text-slate-500 leading-normal italic">
+
</p>
</div>
</div>
+18
View File
@@ -79,6 +79,24 @@ class CommandGuard:
self._reply_group_required(message)
return False
def check_daily_query_limit(self, message: Any, query_type: str, limit: int) -> bool:
user = message.from_user
result = self.io_layer.db.track_query_usage(user.id, query_type)
if result.get("allowed"):
return True
if result.get("reason") == "user_missing":
self.io_layer.bot.reply_to(message, "⚠️ 用户未注册,请先在群内发言。")
return False
if result.get("reason") == "daily_limit":
used = int(result.get("used") or 0)
self.io_layer.send_query_message(
message,
f"❌ 今日 <b>/{query_type}</b> 免费次数已用完 ({used}/{limit})\n请明天再来,或通过群内发言赚取积分。",
parse_mode="HTML",
)
return False
return True
def ensure_access_and_points(self, message: Any, cost: int, command_label: str) -> bool:
if not self.ensure_group_member(message, command_label):
return False
+4 -1
View File
@@ -10,7 +10,7 @@ from src.bot.command_guard import CommandGuard
from src.bot.io_layer import BotIOLayer
from src.bot.observability import CommandTrace
from src.bot.services.city_command_service import CityCommandService
from src.bot.settings import CITY_QUERY_COST
from src.bot.settings import CITY_DAILY_FREE_LIMIT, CITY_QUERY_COST
def _is_city_command(message: Any) -> bool:
command = extract_command_name(
@@ -77,6 +77,9 @@ class CityCommandHandler:
return
city_name = str(resolved.city_name)
if not self.guard.check_daily_query_limit(message, "city", CITY_DAILY_FREE_LIMIT):
trace.set_status("blocked", "daily_query_limit")
return
if not self.guard.ensure_access_and_points(message, CITY_QUERY_COST, "/city"):
trace.set_status("blocked", "guard_rejected")
return
+4 -1
View File
@@ -10,7 +10,7 @@ from src.bot.command_guard import CommandGuard
from src.bot.io_layer import BotIOLayer
from src.bot.observability import CommandTrace
from src.bot.services.deb_command_service import DebCommandService
from src.bot.settings import DEB_QUERY_COST
from src.bot.settings import DEB_DAILY_FREE_LIMIT, DEB_QUERY_COST
def _is_deb_command(message: Any) -> bool:
command = extract_command_name(
@@ -75,6 +75,9 @@ class DebCommandHandler:
)
return
if not self.guard.check_daily_query_limit(message, "deb", DEB_DAILY_FREE_LIMIT):
trace.set_status("blocked", "daily_query_limit")
return
if not self.guard.ensure_access_and_points(message, DEB_QUERY_COST, "/deb"):
trace.set_status("blocked", "guard_rejected")
return
+12 -5
View File
@@ -7,7 +7,9 @@ from typing import Any, Dict, Optional, Tuple
from loguru import logger
from src.bot.settings import (
CITY_DAILY_FREE_LIMIT,
CITY_QUERY_COST,
DEB_DAILY_FREE_LIMIT,
DEB_QUERY_COST,
MESSAGE_COOLDOWN_SEC,
MESSAGE_DAILY_CAP,
@@ -176,8 +178,8 @@ class BotIOLayer:
return (
"🚀 <b>PolyWeather 天气查询机器人</b>\n\n"
"可用指令:\n"
f"/city [城市名] 或 /pwcity [城市名] - 查询城市天气预测与实测 (消耗 {CITY_QUERY_COST} 积分)\n"
f"/deb [城市名] 或 /pwdeb [城市名] - 查看 DEB 融合预测准确率 (消耗 {DEB_QUERY_COST} 积分)\n"
f"/city [城市名] 或 /pwcity [城市名] - 查询城市天气预测与实测 (免费, 每日 {CITY_DAILY_FREE_LIMIT} )\n"
f"/deb [城市名] 或 /pwdeb [城市名] - 查看 DEB 融合预测准确率 (免费, 每日 {DEB_DAILY_FREE_LIMIT} )\n"
"/markets - 私聊机器人查看当前市场监控摘要\n"
"/top - 查看积分排行榜\n"
"/id - 获取当前聊天的 Chat ID\n\n"
@@ -189,8 +191,9 @@ class BotIOLayer:
"📌 <i>私有频道用于接收自动推送;手动查看市场概览请私聊机器人发送 <code>/markets</code>。</i>\n\n"
"🔐 <i>/city 与 /deb 仅限官方群成员使用。</i>\n\n"
"示例: <code>/city 伦敦</code> 或 <code>/pwcity 伦敦</code>\n"
f"💡 <i>提示: 每日签到(有效发言满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
f"每日上限 {MESSAGE_DAILY_CAP} 分。</i>"
f"💡 <i>提示: 群内有效发言({MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
f"每日上限 {MESSAGE_DAILY_CAP} 分。"
f"首次发言额外奖励 <b>20</b> 积分,每日首条消息 +<b>2</b> 积分。</i>"
)
def build_points_rank_text(self, user: Any) -> str:
@@ -225,6 +228,10 @@ class BotIOLayer:
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
rank_text += "────────────────────\n"
rank_text += (
"👤 <b>我的状态:</b>\n"
@@ -233,7 +240,7 @@ class BotIOLayer:
f"┣ 本周排名: <code>{weekly_rank_text}</code>\n"
f"┣ 本周发言积分: <code>{weekly_points}</code>\n"
f"┣ 今日发言积分: <code>{daily_points}/{MESSAGE_DAILY_CAP}</code>\n"
f"┗ /city 消耗: <code>{CITY_QUERY_COST}</code> | /deb 消耗: <code>{DEB_QUERY_COST}</code>"
f"┗ /city 免费 ({city_used}/{CITY_DAILY_FREE_LIMIT}) | /deb 免费 ({deb_used}/{DEB_DAILY_FREE_LIMIT})"
)
return rank_text
+11 -2
View File
@@ -20,5 +20,14 @@ MESSAGE_MIN_LENGTH = _env_int("POLYWEATHER_BOT_MESSAGE_MIN_LENGTH", 3, min_value
MESSAGE_COOLDOWN_SEC = _env_int("POLYWEATHER_BOT_MESSAGE_COOLDOWN_SEC", 30, min_value=0)
# Optional per-chat override map, parsed in BotIOLayer:
# POLYWEATHER_BOT_MESSAGE_COOLDOWN_BY_CHAT="-1003586303099:10,-1003539418691:20"
CITY_QUERY_COST = _env_int("POLYWEATHER_BOT_CITY_QUERY_COST", 2, min_value=0)
DEB_QUERY_COST = _env_int("POLYWEATHER_BOT_DEB_QUERY_COST", 2, min_value=0)
CITY_QUERY_COST = _env_int("POLYWEATHER_BOT_CITY_QUERY_COST", 0, min_value=0)
DEB_QUERY_COST = _env_int("POLYWEATHER_BOT_DEB_QUERY_COST", 0, min_value=0)
CITY_DAILY_FREE_LIMIT = _env_int("POLYWEATHER_BOT_CITY_DAILY_FREE_LIMIT", 10, min_value=1)
DEB_DAILY_FREE_LIMIT = _env_int("POLYWEATHER_BOT_DEB_DAILY_FREE_LIMIT", 10, min_value=1)
FIRST_MESSAGE_BONUS = _env_int("POLYWEATHER_BOT_FIRST_MESSAGE_BONUS", 2, min_value=0)
WELCOME_BONUS = _env_int("POLYWEATHER_BOT_WELCOME_BONUS", 20, min_value=0)
WEEKLY_PARTICIPATION_BONUS = _env_int("POLYWEATHER_BOT_WEEKLY_PARTICIPATION_BONUS", 5, min_value=0)
WEEKLY_ACTIVE_BONUS = _env_int("POLYWEATHER_BOT_WEEKLY_ACTIVE_BONUS", 15, min_value=0)
WEEKLY_ACTIVE_THRESHOLD = _env_int("POLYWEATHER_BOT_WEEKLY_ACTIVE_THRESHOLD", 20, min_value=1)
+54 -5
View File
@@ -14,6 +14,11 @@ except Exception: # pragma: no cover - Python < 3.9 fallback
import requests
from loguru import logger
from src.bot.settings import (
WEEKLY_ACTIVE_BONUS,
WEEKLY_ACTIVE_THRESHOLD,
WEEKLY_PARTICIPATION_BONUS,
)
from src.database.db_manager import DBManager
from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env
@@ -69,11 +74,11 @@ def _compute_target_week_key(
def _reward_rule_for_rank(rank: int) -> Optional[Tuple[int, int]]:
if rank == 1:
return 500, 7
return 200, 7
if rank in (2, 3):
return 300, 3
return 100, 3
if 4 <= rank <= 10:
return 150, 0
return 50, 0
return None
@@ -170,10 +175,12 @@ def _render_settle_report(
week_key: str,
winners: List[Dict[str, Any]],
skipped: int,
participation_count: int = 0,
active_count: int = 0,
) -> str:
lines = [f"🏆 <b>PolyWeather 周榜奖励已结算 ({week_key})</b>", "────────────────────"]
if not winners:
lines.append("本周无有效活跃用户,未发放奖励")
lines.append("本周无上榜用户")
else:
medals = {1: "🥇", 2: "🥈", 3: "🥉"}
for row in winners:
@@ -186,6 +193,11 @@ def _render_settle_report(
lines.append(f"{medal} {name}: +{points_bonus} 积分{pro_text}")
if skipped > 0:
lines.append(f"(重复发放保护已生效,跳过 {skipped} 条)")
if participation_count > 0 or active_count > 0:
lines.append("────────────────────")
lines.append(f"🎁 参与奖 +{WEEKLY_PARTICIPATION_BONUS} 积分: {participation_count}")
if active_count > 0:
lines.append(f"🔥 活跃奖 +{WEEKLY_ACTIVE_BONUS} 积分 (周积分≥{WEEKLY_ACTIVE_THRESHOLD}): {active_count}")
return "\n".join(lines)
@@ -302,8 +314,39 @@ def _runner(bot: Any) -> None:
"winner_count": len(winners),
"skipped_count": skipped,
"winners": winners,
"participation_count": 0,
"active_count": 0,
"settled_at": datetime.now(timezone.utc).isoformat(),
}
winner_ids = {int(w.get("telegram_id") or 0) for w in winners}
participants = db.get_weekly_participation_candidates(week_key, winner_ids)
participation_count = 0
active_count = 0
for p in participants:
tid = int(p.get("telegram_id") or 0)
if tid <= 0:
continue
weekly_pts = int(p.get("weekly_points") or 0)
is_active = weekly_pts >= WEEKLY_ACTIVE_THRESHOLD
bonus = WEEKLY_PARTICIPATION_BONUS + (WEEKLY_ACTIVE_BONUS if is_active else 0)
if bonus <= 0:
continue
applied = db.apply_weekly_reward_payout(
week_key=week_key,
telegram_id=tid,
rank=0,
username=str(p.get("username") or f"user_{tid}"),
points_bonus=bonus,
pro_days=0,
)
if applied:
participation_count += 1
if is_active:
active_count += 1
summary["participation_count"] = participation_count
summary["active_count"] = active_count
db.mark_weekly_reward_settled(
week_key=week_key,
winners_count=len(winners),
@@ -316,7 +359,13 @@ def _runner(bot: Any) -> None:
skipped,
)
if announce and chat_ids:
message = _render_settle_report(week_key=week_key, winners=winners, skipped=skipped)
message = _render_settle_report(
week_key=week_key,
winners=winners,
skipped=skipped,
participation_count=participation_count,
active_count=active_count,
)
for chat_id in chat_ids:
try:
bot.send_message(
+108 -9
View File
@@ -286,6 +286,10 @@ class DBManager:
self._ensure_column(conn, "users", "weekly_points_week", "TEXT")
self._ensure_column(conn, "users", "supabase_user_id", "TEXT")
self._ensure_column(conn, "users", "supabase_email", "TEXT")
self._ensure_column(conn, "users", "welcome_bonus_claimed", "INTEGER DEFAULT 0")
self._ensure_column(conn, "users", "daily_city_queries", "INTEGER DEFAULT 0")
self._ensure_column(conn, "users", "daily_deb_queries", "INTEGER DEFAULT 0")
self._ensure_column(conn, "users", "daily_queries_date", "TEXT")
# Migrate legacy one-to-one binding column into mapping table.
conn.execute(
"""
@@ -785,6 +789,16 @@ class DBManager:
(int(telegram_id), wk, pts, datetime.now().isoformat()),
)
@staticmethod
def _read_bonus_config(env_key: str, fallback: int) -> int:
raw = os.getenv(env_key)
if raw is None or raw.strip() == "":
return fallback
try:
return max(0, int(raw))
except Exception:
return fallback
def _ensure_column(self, conn: sqlite3.Connection, table: str, column: str, ddl: str) -> None:
existing = {
row[1]
@@ -1253,7 +1267,8 @@ class DBManager:
)
cursor = conn.execute(
"""
SELECT points, daily_points, daily_points_date, weekly_points, weekly_points_week, last_message_at
SELECT points, daily_points, daily_points_date, weekly_points, weekly_points_week, last_message_at,
message_count, welcome_bonus_claimed
FROM users WHERE telegram_id = ?
""",
(telegram_id,),
@@ -1334,6 +1349,7 @@ class DBManager:
remaining = max(0, daily_cap - daily_points)
points_added = min(max(0, points_to_add), remaining)
if points_added <= 0:
conn.commit()
return {
"awarded": False,
"reason": "daily_cap",
@@ -1341,23 +1357,38 @@ class DBManager:
"weekly_points": weekly_points,
}
welcome_bonus = 0
first_message_bonus = 0
is_first_message_of_day = daily_points == 0
is_new_user = int(row["message_count"] or 0) == 0 and not int(row["welcome_bonus_claimed"] or 0)
if is_new_user:
welcome_bonus = self._read_bonus_config("POLYWEATHER_BOT_WELCOME_BONUS", 20)
if is_first_message_of_day:
first_message_bonus = self._read_bonus_config("POLYWEATHER_BOT_FIRST_MESSAGE_BONUS", 2)
total_added = points_added + welcome_bonus + first_message_bonus
conn.execute("""
UPDATE users
UPDATE users
SET message_count = message_count + 1,
points = points + ?,
daily_points = ?,
daily_points_date = ?,
weekly_points = ?,
weekly_points_week = ?,
last_message_at = ?
last_message_at = ?,
welcome_bonus_claimed = MAX(welcome_bonus_claimed, ?)
WHERE telegram_id = ?
""", (
points_added,
daily_points + points_added,
total_added,
daily_points + total_added,
today_str,
weekly_points + points_added,
weekly_points + total_added,
week_key,
now.isoformat(),
1 if welcome_bonus > 0 else 0,
telegram_id,
))
conn.execute(
@@ -1372,18 +1403,56 @@ class DBManager:
conn,
telegram_id=telegram_id,
week_key=week_key,
points=weekly_points + points_added,
points=weekly_points + total_added,
)
conn.commit()
return {
"awarded": True,
"reason": "ok",
"points_added": points_added,
"daily_points": daily_points + points_added,
"weekly_points": weekly_points + points_added,
"welcome_bonus": welcome_bonus,
"first_message_bonus": first_message_bonus,
"total_added": total_added,
"daily_points": daily_points + total_added,
"weekly_points": weekly_points + total_added,
"weekly_week": week_key,
}
def track_query_usage(self, telegram_id: int, query_type: str) -> Dict[str, Any]:
today_str = datetime.now().strftime("%Y-%m-%d")
column = "daily_city_queries" if query_type == "city" else "daily_deb_queries"
limit = (
self._read_bonus_config("POLYWEATHER_BOT_CITY_DAILY_FREE_LIMIT", 10)
if query_type == "city"
else self._read_bonus_config("POLYWEATHER_BOT_DEB_DAILY_FREE_LIMIT", 10)
)
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(
f"SELECT {column}, daily_queries_date FROM users WHERE telegram_id = ?",
(telegram_id,),
).fetchone()
if not row:
return {"allowed": False, "reason": "user_missing", "used": 0, "limit": limit}
date = row["daily_queries_date"] or ""
used = int(row[column] or 0) if date == today_str else 0
if used >= limit:
return {"allowed": False, "reason": "daily_limit", "used": used, "limit": limit}
new_used = used + 1
conn.execute(
f"""
UPDATE users
SET {column} = ?, daily_queries_date = ?
WHERE telegram_id = ?
""",
(new_used, today_str, telegram_id),
)
conn.commit()
return {"allowed": True, "used": new_used, "limit": limit}
def spend_points(self, telegram_id: int, amount: int) -> Dict[str, Any]:
if amount <= 0:
user = self.get_user(telegram_id)
@@ -1624,6 +1693,36 @@ class DBManager:
).fetchall()
return [dict(row) for row in rows]
def get_weekly_participation_candidates(self, week_key: str, exclude_ids: set):
wk = self._safe_week_key(week_key)
if not wk:
return []
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT *
FROM (
SELECT
u.telegram_id,
u.username,
COALESCE(a.points,
CASE
WHEN u.weekly_points_week = ? THEN COALESCE(u.weekly_points, 0)
ELSE 0
END
) AS weekly_points
FROM users u
LEFT JOIN weekly_points_archive a
ON a.telegram_id = u.telegram_id
AND a.week_key = ?
) ranked
WHERE weekly_points > 0
""",
(wk, wk),
).fetchall()
return [dict(row) for row in rows if int(row["telegram_id"] or 0) not in exclude_ids]
def is_weekly_reward_settled(self, week_key: str) -> bool:
wk = self._safe_week_key(week_key)
if not wk: