From 787d04619bc2b3db6044e7d4a0c699b1edcefaa5 Mon Sep 17 00:00:00 2001 From: AmandaloveYang <2569718930@qq.com> Date: Mon, 11 May 2026 13:35:52 +0800 Subject: [PATCH] feat: revamp points/reward system for inclusive engagement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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 --- frontend/components/account/AccountCenter.tsx | 8 +- src/bot/command_guard.py | 18 +++ src/bot/handlers/city.py | 5 +- src/bot/handlers/deb.py | 5 +- src/bot/io_layer.py | 17 ++- src/bot/settings.py | 13 +- src/bot/weekly_reward_loop.py | 59 ++++++++- src/database/db_manager.py | 117 ++++++++++++++++-- 8 files changed, 215 insertions(+), 27 deletions(-) diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index cc806241..dbf82d8a 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -2602,7 +2602,7 @@ export function AccountCenter() { Top 1 - +500 积分 & 7天Pro + +200 积分 & 7天Pro
@@ -2613,7 +2613,7 @@ export function AccountCenter() { Top 2-3 - +300 积分 & 3天Pro + +100 积分 & 3天Pro
@@ -2624,7 +2624,7 @@ export function AccountCenter() { Top 4-10 - +150 积分 + +50 积分
@@ -2632,7 +2632,7 @@ export function AccountCenter() {

- 积分规则:群内有效发言(自动防刷检测)。每周一零点结算并重置周积分榜。 + 积分规则:群内有效发言(自动防刷检测)+ 每日首条发言额外奖励。每周一零点结算周榜,所有活跃用户均享参与奖。

diff --git a/src/bot/command_guard.py b/src/bot/command_guard.py index 94ebbce0..b22531f2 100644 --- a/src/bot/command_guard.py +++ b/src/bot/command_guard.py @@ -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"❌ 今日 /{query_type} 免费次数已用完 ({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 diff --git a/src/bot/handlers/city.py b/src/bot/handlers/city.py index c547ad52..969495ff 100644 --- a/src/bot/handlers/city.py +++ b/src/bot/handlers/city.py @@ -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 diff --git a/src/bot/handlers/deb.py b/src/bot/handlers/deb.py index 39e823cb..c7743e2a 100644 --- a/src/bot/handlers/deb.py +++ b/src/bot/handlers/deb.py @@ -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 diff --git a/src/bot/io_layer.py b/src/bot/io_layer.py index 42a1879c..14cfd758 100644 --- a/src/bot/io_layer.py +++ b/src/bot/io_layer.py @@ -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 ( "🚀 PolyWeather 天气查询机器人\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: "📌 私有频道用于接收自动推送;手动查看市场概览请私聊机器人发送 /markets\n\n" "🔐 /city 与 /deb 仅限官方群成员使用。\n\n" "示例: /city 伦敦/pwcity 伦敦\n" - f"💡 提示: 每日签到(有效发言满 {MESSAGE_MIN_LENGTH} 字)获得 {MESSAGE_POINTS} 积分," - f"每日上限 {MESSAGE_DAILY_CAP} 分。" + f"💡 提示: 群内有效发言(满 {MESSAGE_MIN_LENGTH} 字)获得 {MESSAGE_POINTS} 积分," + f"每日上限 {MESSAGE_DAILY_CAP} 分。" + f"首次发言额外奖励 20 积分,每日首条消息 +2 积分。" ) 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 += ( "👤 我的状态:\n" @@ -233,7 +240,7 @@ class BotIOLayer: f"┣ 本周排名: {weekly_rank_text}\n" f"┣ 本周发言积分: {weekly_points}\n" f"┣ 今日发言积分: {daily_points}/{MESSAGE_DAILY_CAP}\n" - f"┗ /city 消耗: {CITY_QUERY_COST} | /deb 消耗: {DEB_QUERY_COST}" + f"┗ /city 免费 ({city_used}/{CITY_DAILY_FREE_LIMIT}) | /deb 免费 ({deb_used}/{DEB_DAILY_FREE_LIMIT})" ) return rank_text diff --git a/src/bot/settings.py b/src/bot/settings.py index bb48c62f..196ed911 100644 --- a/src/bot/settings.py +++ b/src/bot/settings.py @@ -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) diff --git a/src/bot/weekly_reward_loop.py b/src/bot/weekly_reward_loop.py index c2174960..b0f69c35 100644 --- a/src/bot/weekly_reward_loop.py +++ b/src/bot/weekly_reward_loop.py @@ -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"🏆 PolyWeather 周榜奖励已结算 ({week_key})", "────────────────────"] 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( diff --git a/src/database/db_manager.py b/src/database/db_manager.py index b8bb67c0..f177aebc 100644 --- a/src/database/db_manager.py +++ b/src/database/db_manager.py @@ -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: