Selaa lähdekoodia

feat: add check-in attendance variables

AI-Co-Authored-By: Codex
chendeben 1 viikko sitten
vanhempi
sitoutus
16eb21020b

+ 1 - 1
admin-web/src/pages/ChatDetail.tsx

@@ -1346,7 +1346,7 @@ function PointRulesTab({ chatId }: { chatId: string }) {
         <section className="rule-group" style={{ marginTop: 16 }}>
           <h3 className="rule-group-title">签到提示语</h3>
           <Typography.Paragraph type="secondary">
-            可用变量:{'{points}'} 表示本次签到积分,{'{balance}'} 表示签到后的当前余额。
+            可用变量:{'{points}'} 本次积分、{'{balance}'} 当前余额、{'{rank}'} 今日第几个签到、{'{streak}'} 连续签到天数、{'{month_days}'} 本月累计签到天数。
           </Typography.Paragraph>
           <Form.Item
             name="checkin_success_message"

+ 1 - 1
admin-web/src/pages/Points.tsx

@@ -346,7 +346,7 @@ function RulesEditor({ chatId }: { chatId: string }) {
       <section className="rule-group" style={{ marginTop: 16 }}>
         <h3 className="rule-group-title">签到提示语</h3>
         <Typography.Paragraph type="secondary">
-          可用变量:{'{points}'} 表示本次签到积分,{'{balance}'} 表示签到后的当前余额。
+          可用变量:{'{points}'} 本次积分、{'{balance}'} 当前余额、{'{rank}'} 今日第几个签到、{'{streak}'} 连续签到天数、{'{month_days}'} 本月累计签到天数。
         </Typography.Paragraph>
         <Form.Item
           name="checkin_success_message"

+ 1 - 0
admin-web/tests/unit/Points.test.tsx

@@ -78,6 +78,7 @@ it('展示余额排行、流水并保存积分规则', async () => {
   fireEvent.click(screen.getByRole('tab', { name: '自动积分规则' }));
   expect(await screen.findByRole('heading', { name: '签到' })).toBeInTheDocument();
   expect(screen.getByRole('heading', { name: '签到提示语' })).toBeInTheDocument();
+  expect(screen.getByText(/\{rank\}.*\{streak\}.*\{month_days\}/)).toBeInTheDocument();
   expect(screen.getByRole('switch', { name: '底部签到按钮' })).toBeChecked();
   expect(screen.getByLabelText('签到成功提示')).toHaveValue(rules.checkin_success_message);
   expect(screen.getByLabelText('今日已签到提示')).toHaveValue(rules.checkin_already_message);

+ 92 - 0
tests/test_points.py

@@ -170,6 +170,98 @@ async def test_checkin_messages_are_normalized_and_persisted(app_modules):
     assert loaded["checkin_success_message"] == "获得 {points},余额 {balance}"
 
 
+async def test_checkin_metrics_include_daily_rank_streak_and_month_total(app_modules):
+    points = app_modules.load("wbb.utils.dbpoints")
+    await points.set_point_rules(-100, {"enabled": True, "timezone": "Asia/Shanghai"})
+    first_day = datetime(2026, 2, 1, 0, 0, tzinfo=UTC)
+
+    await points.award_checkin(
+        chat_id=-100,
+        user_id=11,
+        username="first",
+        first_name="First",
+        now=first_day,
+    )
+    await points.award_checkin(
+        chat_id=-100,
+        user_id=10,
+        username="alice",
+        first_name="Alice",
+        now=first_day + timedelta(minutes=1),
+    )
+    first_metrics = await points.get_checkin_metrics(
+        chat_id=-100,
+        user_id=10,
+        now=first_day + timedelta(minutes=1),
+    )
+    assert first_metrics == {"rank": 2, "streak": 1, "month_days": 1}
+
+    second_day = first_day + timedelta(days=1)
+    await points.award_checkin(
+        chat_id=-100,
+        user_id=10,
+        username="alice",
+        first_name="Alice",
+        now=second_day,
+    )
+    second_metrics = await points.get_checkin_metrics(
+        chat_id=-100,
+        user_id=10,
+        now=second_day,
+    )
+    assert second_metrics == {"rank": 1, "streak": 2, "month_days": 2}
+    _, duplicate = await points.award_checkin(
+        chat_id=-100,
+        user_id=10,
+        username="alice",
+        first_name="Alice",
+        now=second_day + timedelta(hours=1),
+    )
+    assert duplicate is False
+    assert await points.get_checkin_metrics(
+        chat_id=-100,
+        user_id=10,
+        now=second_day + timedelta(hours=1),
+    ) == {"rank": 1, "streak": 2, "month_days": 2}
+
+    fourth_day = first_day + timedelta(days=3)
+    await points.award_checkin(
+        chat_id=-100,
+        user_id=10,
+        username="alice",
+        first_name="Alice",
+        now=fourth_day,
+    )
+    fourth_metrics = await points.get_checkin_metrics(
+        chat_id=-100,
+        user_id=10,
+        now=fourth_day,
+    )
+    assert fourth_metrics == {"rank": 1, "streak": 1, "month_days": 3}
+
+
+async def test_checkin_streak_continues_across_month_boundary(app_modules):
+    points = app_modules.load("wbb.utils.dbpoints")
+    await points.set_point_rules(-200, {"enabled": True, "timezone": "Asia/Shanghai"})
+    february_last_day = datetime(2026, 2, 28, 0, 0, tzinfo=UTC)
+    march_first_day = february_last_day + timedelta(days=1)
+
+    for current in (february_last_day, march_first_day):
+        await points.award_checkin(
+            chat_id=-200,
+            user_id=20,
+            username="bob",
+            first_name="Bob",
+            now=current,
+        )
+
+    assert await points.get_checkin_metrics(
+        chat_id=-200,
+        user_id=20,
+        now=march_first_day,
+    ) == {"rank": 1, "streak": 2, "month_days": 1}
+
+
 async def test_activity_cooldown_duplicate_content_and_daily_cap(app_modules):
     points = app_modules.load("wbb.utils.dbpoints")
     await points.set_point_rules(

+ 16 - 3
tests/test_points_commands.py

@@ -59,11 +59,22 @@ async def test_checkin_schedules_user_and_bot_messages_for_cleanup(
         AsyncMock(
             return_value={
                 "checkin_points": 10,
-                "checkin_success_message": "领取成功 +{points},余额 {balance}",
-                "checkin_already_message": "今天领过了,余额 {balance}",
+                "checkin_success_message": (
+                    "领取成功 +{points},余额 {balance},今日第 {rank} 个,"
+                    "连续 {streak} 天,本月 {month_days} 天"
+                ),
+                "checkin_already_message": (
+                    "今天领过了,余额 {balance},今日第 {rank} 个,"
+                    "连续 {streak} 天,本月 {month_days} 天"
+                ),
             }
         ),
     )
+    monkeypatch.setattr(
+        points_module,
+        "get_checkin_metrics",
+        AsyncMock(return_value={"rank": 3, "streak": 5, "month_days": 12}),
+    )
     monkeypatch.setattr(
         points_module,
         "schedule_group_cleanup",
@@ -73,7 +84,9 @@ async def test_checkin_schedules_user_and_bot_messages_for_cleanup(
     response = await points_module.points_checkin_command(None, message)
 
     expected_message = (
-        "领取成功 +10,余额 10" if created else "今天领过了,余额 10"
+        "领取成功 +10,余额 10,今日第 3 个,连续 5 天,本月 12 天"
+        if created
+        else "今天领过了,余额 10,今日第 3 个,连续 5 天,本月 12 天"
     )
     assert message.reply_text.await_args.args == (expected_message,)
     assert (

+ 17 - 0
wbb/modules/points.py

@@ -27,12 +27,14 @@ from wbb.utils.dbpoints import (
     adjust_points,
     award_activity,
     award_checkin,
+    get_checkin_metrics,
     get_point_account,
     get_point_rules,
     list_point_accounts,
     list_point_transactions,
     reconcile_all_accounts,
     set_points,
+    utc_now,
 )
 from wbb.utils.i18n import point_source_label
 
@@ -104,10 +106,16 @@ def _checkin_message(
     *,
     points: int,
     balance: int,
+    rank: int,
+    streak: int,
+    month_days: int,
 ) -> str:
     return (
         template.replace("{points}", str(points))
         .replace("{balance}", str(balance))
+        .replace("{rank}", str(rank))
+        .replace("{streak}", str(streak))
+        .replace("{month_days}", str(month_days))
     )
 
 
@@ -154,6 +162,7 @@ async def points_balance_command(_, message: Message):
 async def points_checkin_command(_, message: Message):
     if not message.from_user:
         return await _reply_checkin(message, "匿名管理员无法签到。")
+    now = utc_now()
     try:
         account, created = await award_checkin(
             chat_id=message.chat.id,
@@ -161,6 +170,12 @@ async def points_checkin_command(_, message: Message):
             username=message.from_user.username,
             first_name=message.from_user.first_name,
             display_name=_user_display_name(message.from_user),
+            now=now,
+        )
+        metrics = await get_checkin_metrics(
+            chat_id=message.chat.id,
+            user_id=message.from_user.id,
+            now=now,
         )
     except PointsError as exc:
         return await _reply_checkin(message, str(exc))
@@ -177,6 +192,7 @@ async def points_checkin_command(_, message: Message):
                 ),
                 points=points,
                 balance=balance,
+                **metrics,
             ),
         )
     return await _reply_checkin(
@@ -188,6 +204,7 @@ async def points_checkin_command(_, message: Message):
             ),
             points=points,
             balance=balance,
+            **metrics,
         ),
     )
 

+ 89 - 0
wbb/utils/dbpoints.py

@@ -211,6 +211,13 @@ async def ensure_point_indexes() -> None:
         await transactionsdb.create_index(
             [("chat_id", ASCENDING), ("source", ASCENDING), ("created_at", DESCENDING)]
         )
+        await transactionsdb.create_index(
+            [
+                ("chat_id", ASCENDING),
+                ("source", ASCENDING),
+                ("idempotency_key", ASCENDING),
+            ]
+        )
         await rulesdb.create_index([("chat_id", ASCENDING)], unique=True)
         await rate_limitsdb.create_index([("rate_key", ASCENDING)], unique=True)
         await rate_limitsdb.create_index("expires_at", expireAfterSeconds=0)
@@ -676,6 +683,88 @@ async def award_checkin(
     )
 
 
+async def get_checkin_metrics(
+    *,
+    chat_id: int,
+    user_id: int,
+    now: datetime | None = None,
+) -> dict[str, int]:
+    await ensure_point_indexes()
+    rules = await get_point_rules(chat_id)
+    now = _as_utc(now or utc_now())
+    try:
+        zone = ZoneInfo(rules["timezone"])
+    except ZoneInfoNotFoundError:
+        zone = ZoneInfo("Asia/Shanghai")
+    day = now.astimezone(zone).date().isoformat()
+    daily_key_pattern = (
+        rf"^checkin:{re.escape(str(int(chat_id)))}:\d+:{re.escape(day)}$"
+    )
+
+    daily_cursor = transactionsdb.find(
+        {
+            "chat_id": int(chat_id),
+            "source": SOURCE_CHECKIN,
+            "idempotency_key": {"$regex": daily_key_pattern},
+        },
+        {"user_id": 1},
+    ).sort([("created_at", ASCENDING), ("_id", ASCENDING)])
+    daily_entries = [item async for item in daily_cursor]
+    rank = next(
+        (
+            index
+            for index, item in enumerate(daily_entries, 1)
+            if int(item.get("user_id", 0)) == int(user_id)
+        ),
+        0,
+    )
+
+    user_key_prefix = (
+        f"checkin:{int(chat_id)}:{int(user_id)}:"
+    )
+    month_key_pattern = (
+        rf"^{re.escape(user_key_prefix + day[:7])}-\d{{2}}$"
+    )
+    month_days = await transactionsdb.count_documents(
+        {
+            "chat_id": int(chat_id),
+            "user_id": int(user_id),
+            "source": SOURCE_CHECKIN,
+            "idempotency_key": {"$regex": month_key_pattern},
+        }
+    )
+    history_key_pattern = (
+        rf"^{re.escape(user_key_prefix)}\d{{4}}-\d{{2}}-\d{{2}}$"
+    )
+    history_cursor = transactionsdb.find(
+        {
+            "chat_id": int(chat_id),
+            "user_id": int(user_id),
+            "source": SOURCE_CHECKIN,
+            "idempotency_key": {"$regex": history_key_pattern},
+        },
+        {"idempotency_key": 1},
+    )
+    checkin_days = set()
+    async for item in history_cursor:
+        try:
+            checkin_days.add(
+                datetime.strptime(
+                    str(item["idempotency_key"]).rsplit(":", 1)[-1],
+                    "%Y-%m-%d",
+                ).date()
+            )
+        except (KeyError, TypeError, ValueError):
+            continue
+    expected_day = datetime.strptime(day, "%Y-%m-%d").date()
+    streak = 0
+    while expected_day in checkin_days:
+        streak += 1
+        expected_day -= timedelta(days=1)
+
+    return {"rank": rank, "streak": streak, "month_days": int(month_days)}
+
+
 async def award_activity(
     *,
     chat_id: int,