Преглед на файлове

feat: support scheduled giveaway registration and draws

AI-Co-Authored-By: Codex
chendeben преди 1 седмица
родител
ревизия
8a1d7f7d6b

+ 1 - 0
admin-web/src/components/StatusTag.tsx

@@ -1,6 +1,7 @@
 import { Tag } from 'antd';
 
 const statusMap: Record<string, { color: string; label: string }> = {
+  scheduled: { color: 'cyan', label: '待报名' },
   running: { color: 'processing', label: '进行中' },
   drawing: { color: 'warning', label: '开奖中' },
   canceling: { color: 'warning', label: '退款中' },

+ 3 - 2
admin-web/src/pages/ChatDetail.tsx

@@ -55,6 +55,7 @@ import type {
   RiskRule,
 } from '@/types';
 import { userDisplayName } from '@/utils/users';
+import { giveawayDisplayStatus } from '@/utils/giveaways';
 import { telegramPermissionLabel, telegramStatusLabel } from '@/utils/labels';
 
 interface UploadedMedia {
@@ -1357,9 +1358,9 @@ function ChatGiveawaysTab({ chatId }: { chatId: string }) {
   const columns: ColumnsType<Giveaway> = [
     { title: '编号', dataIndex: 'giveaway_id', width: 100, render: (value) => <Typography.Text code>{value}</Typography.Text> },
     { title: '标题', dataIndex: 'title' },
-    { title: '状态', dataIndex: 'status', width: 100, render: (value) => <StatusTag status={value} /> },
+    { title: '状态', key: 'status', width: 100, render: (_, record) => <StatusTag status={giveawayDisplayStatus(record)} /> },
     { title: '报名消耗', dataIndex: 'entry_cost', width: 100 },
-    { title: '结束时间', dataIndex: 'ends_at', width: 160, render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm') },
+    { title: '开奖时间', dataIndex: 'ends_at', width: 160, render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm') },
   ];
   return (
     <>

+ 3 - 2
admin-web/src/pages/Dashboard.tsx

@@ -10,6 +10,7 @@ import { apiRequest } from '@/services/api';
 import type { Giveaway, PointAccount, PointTransaction } from '@/types';
 import { pointSourceLabel } from '@/utils/labels';
 import { userDisplayName } from '@/utils/users';
+import { giveawayDisplayStatus } from '@/utils/giveaways';
 
 interface DashboardData {
   counts: {
@@ -53,8 +54,8 @@ export default function DashboardPage() {
   const giveawayColumns: ColumnsType<Giveaway> = [
     { title: '编号', dataIndex: 'giveaway_id', width: 100, render: (value) => <Typography.Text code>{value}</Typography.Text> },
     { title: '标题', dataIndex: 'title', ellipsis: true },
-    { title: '状态', dataIndex: 'status', width: 100, render: (value) => <StatusTag status={value} /> },
-    { title: '结束时间', dataIndex: 'ends_at', width: 168, render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm') },
+    { title: '状态', key: 'status', width: 100, render: (_, record) => <StatusTag status={giveawayDisplayStatus(record)} /> },
+    { title: '开奖时间', dataIndex: 'ends_at', width: 168, render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm') },
   ];
 
   const transactionColumns: ColumnsType<PointTransaction> = [

+ 66 - 7
admin-web/src/pages/Giveaways.tsx

@@ -1,5 +1,6 @@
 import {
   Button,
+  DatePicker,
   Descriptions,
   Divider,
   Drawer,
@@ -26,6 +27,7 @@ import { StatusTag } from '@/components/StatusTag';
 import { apiRequest, downloadFile, jsonOptions } from '@/services/api';
 import type { ChatSummary, Giveaway, Paged } from '@/types';
 import { userDisplayName } from '@/utils/users';
+import { giveawayDisplayStatus } from '@/utils/giveaways';
 
 interface Participant {
   giveaway_id: string;
@@ -150,7 +152,7 @@ export default function GiveawaysPage() {
       ),
     },
     { title: '群 ID', dataIndex: 'chat_id', width: 150, responsive: ['md'], className: 'numeric' },
-    { title: '状态', dataIndex: 'status', width: 100, render: (value) => <StatusTag status={value} /> },
+    { title: '状态', key: 'status', width: 100, render: (_, record) => <StatusTag status={giveawayDisplayStatus(record)} /> },
     {
       title: '积分条件',
       key: 'points',
@@ -163,7 +165,17 @@ export default function GiveawaysPage() {
         </Space>
       ),
     },
-    { title: '结束时间', dataIndex: 'ends_at', width: 170, render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm') },
+    {
+      title: '报名 / 开奖时间',
+      key: 'schedule',
+      width: 190,
+      render: (_, record) => (
+        <Space direction="vertical" size={0}>
+          <Typography.Text>{dayjs(record.starts_at || record.created_at).format('YYYY-MM-DD HH:mm')}</Typography.Text>
+          <Typography.Text type="secondary">至 {dayjs(record.ends_at).format('YYYY-MM-DD HH:mm')}</Typography.Text>
+        </Space>
+      ),
+    },
     {
       title: '操作',
       key: 'actions',
@@ -352,18 +364,29 @@ export default function GiveawaysPage() {
         <Form
           form={createForm}
           layout="vertical"
-          initialValues={{ chat_id: chatId, duration_minutes: 60, minimum_points: 0, entry_cost: 0, participation_reward: 0, prizes: [{ name: '一等奖', count: 1, points_reward: 0 }] }}
+          initialValues={{
+            chat_id: chatId,
+            starts_at: dayjs().add(1, 'day').startOf('day'),
+            ends_at: dayjs().add(1, 'day').hour(23).minute(59).second(0).millisecond(0),
+            minimum_points: 0,
+            entry_cost: 0,
+            participation_reward: 0,
+            prizes: [{ name: '一等奖', count: 1, points_reward: 0 }],
+          }}
           onFinish={(values) => {
+            const startsAt = dayjs(values.starts_at);
+            const endsAt = dayjs(values.ends_at);
             Modal.confirm({
               title: '确认创建并发布抽奖?',
-              content: '抽奖消息会立即发送到所选群组。',
+              content: `抽奖消息会立即发送到所选群组。报名时间:${startsAt.format('YYYY-MM-DD HH:mm')} 至 ${endsAt.format('YYYY-MM-DD HH:mm')}。`,
               okText: '确认发布',
               onOk: async () => {
                 await apiRequest(
                   '/giveaways',
                   jsonOptions('POST', {
                     ...values,
-                    ends_at: dayjs().add(values.duration_minutes, 'minute').toISOString(),
+                    starts_at: startsAt.toISOString(),
+                    ends_at: endsAt.toISOString(),
                     confirm: true,
                   }),
                 );
@@ -380,7 +403,41 @@ export default function GiveawaysPage() {
           </Form.Item>
           <Form.Item name="title" label="标题" rules={[{ required: true }]}><Input maxLength={200} /></Form.Item>
           <Form.Item name="description" label="说明"><Input.TextArea rows={3} maxLength={4000} /></Form.Item>
-          <Form.Item name="duration_minutes" label="持续分钟数" rules={[{ required: true }]}><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
+          <Space wrap align="start" size={16} style={{ display: 'flex' }}>
+            <Form.Item
+              name="starts_at"
+              label="报名开始时间"
+              extra="按浏览器当前时区输入"
+              rules={[{ required: true, message: '请选择报名开始时间' }]}
+              style={{ flex: 1, minWidth: 240 }}
+            >
+              <DatePicker showTime={{ format: 'HH:mm' }} format="YYYY-MM-DD HH:mm" style={{ width: '100%' }} />
+            </Form.Item>
+            <Form.Item
+              name="ends_at"
+              label="开奖时间"
+              dependencies={['starts_at']}
+              extra="到时自动公布抽奖结果"
+              rules={[
+                { required: true, message: '请选择开奖时间' },
+                ({ getFieldValue }) => ({
+                  validator(_, value) {
+                    if (!value || !getFieldValue('starts_at')) return Promise.resolve();
+                    if (!dayjs(value).isAfter(dayjs())) {
+                      return Promise.reject(new Error('开奖时间必须晚于当前时间'));
+                    }
+                    if (!dayjs(value).isAfter(dayjs(getFieldValue('starts_at')))) {
+                      return Promise.reject(new Error('开奖时间必须晚于报名开始时间'));
+                    }
+                    return Promise.resolve();
+                  },
+                }),
+              ]}
+              style={{ flex: 1, minWidth: 240 }}
+            >
+              <DatePicker showTime={{ format: 'HH:mm' }} format="YYYY-MM-DD HH:mm" style={{ width: '100%' }} />
+            </Form.Item>
+          </Space>
           <Form.List name="prizes">
             {(fields, { add, remove }) => (
               <>
@@ -425,7 +482,9 @@ export default function GiveawaysPage() {
           <>
             <Descriptions size="small" column={2} style={{ marginBottom: 16 }}>
               <Descriptions.Item label="标题">{selected.title}</Descriptions.Item>
-              <Descriptions.Item label="状态"><StatusTag status={selected.status} /></Descriptions.Item>
+              <Descriptions.Item label="状态"><StatusTag status={giveawayDisplayStatus(selected)} /></Descriptions.Item>
+              <Descriptions.Item label="报名开始">{dayjs(selected.starts_at || selected.created_at).format('YYYY-MM-DD HH:mm')}</Descriptions.Item>
+              <Descriptions.Item label="开奖时间">{dayjs(selected.ends_at).format('YYYY-MM-DD HH:mm')}</Descriptions.Item>
               <Descriptions.Item label="报名消耗">{selected.entry_cost}</Descriptions.Item>
               <Descriptions.Item label="参与奖励">{selected.participation_reward}</Descriptions.Item>
             </Descriptions>

+ 1 - 0
admin-web/src/types.ts

@@ -406,6 +406,7 @@ export interface Giveaway {
   minimum_points: number;
   entry_cost: number;
   participation_reward: number;
+  starts_at?: string;
   ends_at: string;
   created_at: string;
   winners?: Array<{ user_id: string; tier: string; points_reward: number }>;

+ 16 - 0
admin-web/src/utils/giveaways.ts

@@ -0,0 +1,16 @@
+import dayjs from 'dayjs';
+
+import type { Giveaway } from '@/types';
+
+export function giveawayDisplayStatus(
+  giveaway: Pick<Giveaway, 'status' | 'starts_at'>,
+): string {
+  if (
+    giveaway.status === 'running'
+    && giveaway.starts_at
+    && dayjs(giveaway.starts_at).isAfter(dayjs())
+  ) {
+    return 'scheduled';
+  }
+  return giveaway.status;
+}

+ 6 - 0
admin-web/tests/unit/Giveaways.test.tsx

@@ -34,6 +34,7 @@ beforeEach(() => {
           minimum_points: 20,
           entry_cost: 5,
           participation_reward: 2,
+          starts_at: '2026-07-24T00:00:00Z',
           ends_at: '2026-07-25T08:00:00Z',
           created_at: '2026-07-24T08:00:00Z',
         }],
@@ -93,6 +94,8 @@ it('创建表单包含积分联动字段且发布前必须二次确认', async (
   expect(await screen.findByLabelText('最低积分')).toBeInTheDocument();
   expect(screen.getByLabelText('报名消耗')).toBeInTheDocument();
   expect(screen.getByLabelText('参与奖励')).toBeInTheDocument();
+  expect(screen.getByLabelText('报名开始时间')).toBeInTheDocument();
+  expect(screen.getByLabelText('开奖时间')).toBeInTheDocument();
   expect(screen.getByPlaceholderText('中奖积分')).toBeInTheDocument();
 
   fireEvent.change(screen.getByLabelText('标题'), { target: { value: '八月抽奖' } });
@@ -115,8 +118,11 @@ it('创建表单包含积分联动字段且发布前必须二次确认', async (
       minimum_points: 0,
       entry_cost: 0,
       participation_reward: 0,
+      starts_at: expect.any(String),
+      ends_at: expect.any(String),
       confirm: true,
     });
+    expect(JSON.parse(String(call?.[1]?.body))).not.toHaveProperty('duration_minutes');
   });
 });
 

+ 7 - 0
tests/conftest.py

@@ -20,6 +20,7 @@ class FakeTelegramApp:
         self.histories: dict[int, list[object]] = {}
         self.sent_messages: list[tuple[int, str]] = []
         self.sent_photos: list[tuple[int, object, str]] = []
+        self.edited_messages: list[tuple[int, int, str]] = []
 
     async def get_chat_member(self, chat_id: int, user_id: int):
         member = self.members.get((int(chat_id), int(user_id)))
@@ -58,6 +59,12 @@ class FakeTelegramApp:
         self.sent_photos.append((int(chat_id), photo, caption))
         return SimpleNamespace(chat=SimpleNamespace(id=int(chat_id)), id=len(self.sent_photos))
 
+    async def edit_message_text(
+        self, chat_id: int, message_id: int, text: str, **_kwargs
+    ):
+        self.edited_messages.append((int(chat_id), int(message_id), text))
+        return SimpleNamespace(chat=SimpleNamespace(id=int(chat_id)), id=int(message_id))
+
     def on_message(self, *_args, **_kwargs):
         return lambda function: function
 

+ 74 - 0
tests/test_giveaway_points.py

@@ -69,6 +69,80 @@ async def test_entry_cost_duplicate_click_and_cancel_refund(app_modules):
     assert (await points.get_point_account(-100, 10))["balance"] == 100
 
 
+async def test_registration_window_blocks_early_entry_and_refreshes_count(app_modules):
+    giveaway_db = app_modules.load("wbb.utils.dbgiveaway")
+    service = app_modules.load("wbb.services.giveaways")
+    now = datetime.now(UTC)
+    future = await giveaway_db.create_giveaway(
+        chat_id=-100,
+        creator_id=1,
+        creator_name="Admin",
+        title="Scheduled",
+        description="",
+        prizes=[{"name": "Winner", "count": 1}],
+        starts_at=now + timedelta(hours=1),
+        ends_at=now + timedelta(hours=2),
+    )
+
+    status, _ = await service.join_giveaway(
+        future["giveaway_id"],
+        user_id=10,
+        username=None,
+        first_name="Alice",
+    )
+    assert status == "not_started"
+    assert await giveaway_db.count_participants(future["giveaway_id"]) == 0
+    assert await giveaway_db.list_unannounced_started_giveaways() == []
+
+    await giveaway_db.giveawaysdb.update_one(
+        {"giveaway_id": future["giveaway_id"]},
+        {"$set": {"starts_at": now - timedelta(minutes=1)}},
+    )
+    started = await giveaway_db.list_unannounced_started_giveaways()
+    assert [item["giveaway_id"] for item in started] == [future["giveaway_id"]]
+    await giveaway_db.mark_giveaway_start_announced(future["giveaway_id"])
+    assert await giveaway_db.list_unannounced_started_giveaways() == []
+
+    current = await giveaway_db.create_giveaway(
+        chat_id=-100,
+        creator_id=1,
+        creator_name="Admin",
+        title="Open",
+        description="",
+        prizes=[{"name": "Winner", "count": 1}],
+        starts_at=now - timedelta(minutes=1),
+        ends_at=now + timedelta(hours=1),
+    )
+    await giveaway_db.attach_giveaway_message(current["giveaway_id"], -100, 99)
+    status, _ = await service.join_giveaway(
+        current["giveaway_id"],
+        user_id=10,
+        username="alice",
+        first_name="Alice",
+    )
+
+    assert status == "ok"
+    assert len(app_modules.app.edited_messages) == 1
+    assert app_modules.app.edited_messages[0][:2] == (-100, 99)
+    assert "<b>参与人数:</b>1" in app_modules.app.edited_messages[0][2]
+
+
+async def test_registration_start_must_precede_draw_time(app_modules):
+    giveaway_db = app_modules.load("wbb.utils.dbgiveaway")
+    now = datetime.now(UTC)
+    with pytest.raises(ValueError, match="报名开始时间必须早于开奖时间"):
+        await giveaway_db.create_giveaway(
+            chat_id=-100,
+            creator_id=1,
+            creator_name="Admin",
+            title="Invalid schedule",
+            description="",
+            prizes=[{"name": "Winner", "count": 1}],
+            starts_at=now + timedelta(hours=2),
+            ends_at=now + timedelta(hours=1),
+        )
+
+
 async def test_draw_rewards_are_idempotent_under_competing_finish_calls(app_modules):
     points = app_modules.load("wbb.utils.dbpoints")
     giveaway_db = app_modules.load("wbb.utils.dbgiveaway")

+ 8 - 1
wbb/admin/api.py

@@ -2819,6 +2819,12 @@ class AdminApi:
         prizes = body.get("prizes")
         if not isinstance(prizes, list):
             raise ApiProblem("invalid_prizes", "奖项必须是数组。")
+        starts_at = parse_datetime(body.get("starts_at"), "starts_at")
+        ends_at = parse_datetime(body.get("ends_at"), "ends_at")
+        if ends_at <= datetime.now(UTC):
+            raise ApiProblem("invalid_schedule", "开奖时间必须晚于当前时间。")
+        if starts_at >= ends_at:
+            raise ApiProblem("invalid_schedule", "报名开始时间必须早于开奖时间。")
         set_audit(request, "giveaway.create", chat_id=chat_id, summary=str(body.get("title") or ""))
         giveaway = await create_and_publish_giveaway(
             chat_id=chat_id,
@@ -2827,7 +2833,8 @@ class AdminApi:
             title=str(body.get("title") or ""),
             description=str(body.get("description") or ""),
             prizes=prizes,
-            ends_at=parse_datetime(body.get("ends_at"), "ends_at"),
+            starts_at=starts_at,
+            ends_at=ends_at,
             minimum_points=parse_int(body.get("minimum_points", 0), "minimum_points", minimum=0),
             entry_cost=parse_int(body.get("entry_cost", 0), "entry_cost", minimum=0),
             participation_reward=parse_int(body.get("participation_reward", 0), "participation_reward", minimum=0),

+ 18 - 7
wbb/modules/admin_panel.py

@@ -29,6 +29,7 @@ from wbb.services.giveaways import (
     cancel_and_refund_giveaway,
     create_and_publish_giveaway,
     finish_and_publish_giveaway,
+    parse_giveaway_time,
 )
 from wbb.services.member_identity import display_name, non_mention_account_name
 from wbb.services.point_settings import apply_point_rules
@@ -886,18 +887,27 @@ async def _handle_flow_input(message: Message, flow: PendingFlow) -> None:
         return await _queue_confirmation(message, flow)
     if flow.action == "giveaway_create":
         prompts = {
-            "title": ("duration", "请输入持续分钟数。"),
-            "duration": ("prizes", "请输入奖项,如 一等奖:1:50, 二等奖:3:10。"),
+            "title": ("starts_at", "请输入报名开始时间,格式 YYYY-MM-DD HH:mm(北京时间)。"),
+            "starts_at": ("ends_at", "请输入开奖时间,格式 YYYY-MM-DD HH:mm(北京时间)。"),
+            "ends_at": ("prizes", "请输入奖项,如 一等奖:1:50, 二等奖:3:10。"),
             "prizes": ("minimum_points", "请输入最低积分,0 表示不限制。"),
             "minimum_points": ("entry_cost", "请输入报名消耗积分,0 表示免费。"),
             "entry_cost": ("participation_reward", "请输入参与奖励积分,0 表示无奖励。"),
         }
         if flow.step == "title":
             flow.data["title"] = text[:200]
-        elif flow.step == "duration":
-            if not text.isdigit() or int(text) <= 0:
-                return await message.reply_text("请输入大于 0 的整数分钟数。")
-            flow.data["duration"] = int(text)
+        elif flow.step in {"starts_at", "ends_at"}:
+            parsed = parse_giveaway_time(text)
+            if not parsed:
+                return await message.reply_text(
+                    "时间格式不正确,请按 YYYY-MM-DD HH:mm 输入。"
+                )
+            if flow.step == "ends_at":
+                if parsed <= datetime.now(UTC):
+                    return await message.reply_text("开奖时间必须晚于当前时间。")
+                if parsed <= flow.data["starts_at"]:
+                    return await message.reply_text("开奖时间必须晚于报名开始时间。")
+            flow.data[flow.step] = parsed
         elif flow.step == "prizes":
             prizes = []
             for piece in text.split(","):
@@ -1052,7 +1062,8 @@ async def _execute_confirmation(query: CallbackQuery, token: str) -> None:
             title=data["title"],
             description="",
             prizes=data["prizes"],
-            ends_at=datetime.now(UTC) + timedelta(minutes=data["duration"]),
+            starts_at=data["starts_at"],
+            ends_at=data["ends_at"],
             minimum_points=data["minimum_points"],
             entry_cost=data["entry_cost"],
             participation_reward=data["participation_reward"],

+ 27 - 23
wbb/modules/giveaway.py

@@ -2,7 +2,6 @@ from __future__ import annotations
 
 import asyncio
 import re
-from datetime import timedelta
 from html import escape
 from typing import Any
 
@@ -17,7 +16,10 @@ from wbb.services.giveaways import (
     cancel_and_refund_giveaway,
     create_and_publish_giveaway,
     finish_and_publish_giveaway,
+    format_giveaway_time,
     join_giveaway,
+    parse_giveaway_time,
+    refresh_giveaway_message,
     remove_and_optionally_refund_participant,
     render_winners,
     reroll_giveaway,
@@ -32,6 +34,8 @@ from wbb.utils.dbgiveaway import (
     list_participants,
     list_pending_cancellations,
     list_running_giveaways,
+    list_unannounced_started_giveaways,
+    mark_giveaway_start_announced,
     normalize_giveaway_id,
     remove_giveaway_ban,
     utc_now,
@@ -39,12 +43,12 @@ from wbb.utils.dbgiveaway import (
 from wbb.utils.functions import extract_user_and_reason
 
 __MODULE__ = "抽奖管理"
-__HELP__ = """/giveaway <时长> | <奖项> | <标题> | [说明] | [最低积分] | [报名消耗] | [参与奖励]
+__HELP__ = """/giveaway <报名开始时间 ~ 开奖时间> | <奖项> | <标题> | [说明] | [最低积分] | [报名消耗] | [参与奖励]
 在当前群创建抽奖。
 
 奖项格式:`1`、`一等奖:1, 二等奖:3`,或 `一等奖:1:50`;
 最后的 50 表示该奖项每位中奖者获得 50 积分。
-时长单位:s 秒、m 分钟、h 小时、d 天、w 周。
+时间格式:`YYYY-MM-DD HH:mm`,按北京时间计算。
 
 /gjoin <编号> - 报名参加抽奖。
 /glist - 查看进行中的抽奖。
@@ -59,25 +63,18 @@ __HELP__ = """/giveaway <时长> | <奖项> | <标题> | [说明] | [最低积
 MAX_TOTAL_WINNERS = 100
 PARTICIPANT_PREVIEW_LIMIT = 30
 SWEEP_INTERVAL_SECONDS = 30
-_DURATION_RE = re.compile(r"^\s*(\d+)\s*([smhdwSMHDW])\s*$")
-_DURATION_UNITS = {
-    "s": "seconds",
-    "m": "minutes",
-    "h": "hours",
-    "d": "days",
-    "w": "weeks",
-}
 _sweeper_started = False
 
 
-def _parse_duration(raw: str):
-    match = _DURATION_RE.match(raw)
-    if not match:
+def _parse_schedule(raw: str):
+    pieces = re.split(r"\s*(?:~|至)\s*", raw.strip())
+    if len(pieces) != 2:
         return None
-    amount = int(match.group(1))
-    if amount <= 0:
+    starts_at = parse_giveaway_time(pieces[0])
+    ends_at = parse_giveaway_time(pieces[1])
+    if not starts_at or not ends_at or starts_at >= ends_at or ends_at <= utc_now():
         return None
-    return utc_now() + timedelta(**{_DURATION_UNITS[match.group(2).lower()]: amount})
+    return starts_at, ends_at
 
 
 def _parse_prizes(raw: str) -> list[dict[str, Any]] | None:
@@ -116,8 +113,9 @@ def _parse_optional_points(pieces: list[str], index: int) -> int | None:
 def _create_usage() -> str:
     return (
         "<b>用法</b>\n"
-        "/giveaway 1h | 1 | 月度会员抽奖\n"
-        "/giveaway 2d | 一等奖:1:50, 二等奖:3:10 | 活动抽奖 | "
+        "/giveaway 2026-09-22 00:00 ~ 2026-09-22 23:59 | 1 | 月度会员抽奖\n"
+        "/giveaway 2026-09-22 00:00 ~ 2026-09-23 20:00 | "
+        "一等奖:1:50, 二等奖:3:10 | 活动抽奖 | "
         "抽奖说明 | 20 | 5 | 2"
     )
 
@@ -162,6 +160,7 @@ async def _join_user(giveaway_id: str, user) -> str:
         "duplicate": "你已经报名过该抽奖。",
         "missing": "未找到该抽奖。",
         "closed": "该抽奖已经关闭。",
+        "not_started": "该抽奖尚未开始报名。",
         "ended": "该抽奖已经结束。",
         "banned": "你已被禁止参加本群抽奖。",
         "not_member": "只有当前群成员可以参加该抽奖。",
@@ -173,6 +172,9 @@ async def _sweep_due_giveaways() -> None:
     await asyncio.sleep(5)
     while True:
         try:
+            for giveaway in await list_unannounced_started_giveaways():
+                if await refresh_giveaway_message(giveaway):
+                    await mark_giveaway_start_announced(giveaway["giveaway_id"])
             pending = await list_due_giveaways()
             pending.extend(await list_pending_cancellations())
             for giveaway in pending:
@@ -201,13 +203,13 @@ async def create_giveaway_command(_, message: Message):
     pieces = [piece.strip() for piece in payload.split("|")]
     if len(pieces) < 3:
         return await message.reply_text(_create_usage(), parse_mode=ParseMode.HTML)
-    ends_at = _parse_duration(pieces[0])
+    schedule = _parse_schedule(pieces[0])
     prizes = _parse_prizes(pieces[1])
     minimum_points = _parse_optional_points(pieces, 4)
     entry_cost = _parse_optional_points(pieces, 5)
     participation_reward = _parse_optional_points(pieces, 6)
     if (
-        not ends_at
+        not schedule
         or not prizes
         or not pieces[2]
         or minimum_points is None
@@ -215,6 +217,7 @@ async def create_giveaway_command(_, message: Message):
         or participation_reward is None
     ):
         return await message.reply_text(_create_usage(), parse_mode=ParseMode.HTML)
+    starts_at, ends_at = schedule
     giveaway = await create_and_publish_giveaway(
         chat_id=message.chat.id,
         creator_id=_actor_id(message),
@@ -222,6 +225,7 @@ async def create_giveaway_command(_, message: Message):
         title=pieces[2],
         description=pieces[3] if len(pieces) > 3 else "",
         prizes=prizes,
+        starts_at=starts_at,
         ends_at=ends_at,
         minimum_points=minimum_points,
         entry_cost=entry_cost,
@@ -254,11 +258,11 @@ async def list_giveaways_command(_, message: Message):
     lines = ["<b>进行中的抽奖</b>"]
     for giveaway in giveaways:
         lines.append(
-            "#{id} - {title} - {count} 人参与 - 截止于 {ends_at}".format(
+            "#{id} - {title} - {count} 人参与 - 开奖于 {ends_at}".format(
                 id=escape(giveaway["giveaway_id"]),
                 title=escape(giveaway["title"]),
                 count=await count_participants(giveaway["giveaway_id"]),
-                ends_at=giveaway["ends_at"].strftime("%Y-%m-%d %H:%M UTC"),
+                ends_at=format_giveaway_time(giveaway["ends_at"]),
             )
         )
     return await message.reply_text("\n".join(lines), parse_mode=ParseMode.HTML)

+ 52 - 2
wbb/services/giveaways.py

@@ -4,8 +4,10 @@ import asyncio
 import secrets
 from collections import defaultdict
 from contextlib import suppress
+from datetime import UTC, datetime
 from html import escape
 from typing import Any
+from zoneinfo import ZoneInfo
 
 from pyrogram.enums import ChatMemberStatus, ParseMode
 from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup
@@ -33,6 +35,7 @@ from wbb.utils.dbgiveaway import (
     record_reroll,
     remove_participant,
     save_pending_winners,
+    utc_now,
 )
 from wbb.utils.dbpoints import (
     SOURCE_GIVEAWAY_ENTRY,
@@ -48,6 +51,7 @@ from wbb.utils.i18n import giveaway_status_label
 
 _RANDOM = secrets.SystemRandom()
 _giveaway_locks: defaultdict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
+GIVEAWAY_TIMEZONE = ZoneInfo("Asia/Shanghai")
 
 
 class GiveawayServiceError(RuntimeError):
@@ -57,6 +61,22 @@ class GiveawayServiceError(RuntimeError):
         self.status = status
 
 
+def parse_giveaway_time(raw: str) -> datetime | None:
+    try:
+        local_time = datetime.strptime(raw.strip(), "%Y-%m-%d %H:%M")
+    except ValueError:
+        return None
+    return local_time.replace(tzinfo=GIVEAWAY_TIMEZONE).astimezone(UTC)
+
+
+def format_giveaway_time(value: datetime) -> str:
+    return as_utc(value).astimezone(GIVEAWAY_TIMEZONE).strftime("%Y-%m-%d %H:%M")
+
+
+def giveaway_starts_at(giveaway: dict[str, Any]) -> datetime:
+    return as_utc(giveaway.get("starts_at") or giveaway["created_at"])
+
+
 def _participant_mention(participant: dict[str, Any]) -> str:
     user_id = int(participant["user_id"])
     label = (
@@ -81,9 +101,12 @@ def _format_prizes(prizes: list[dict[str, Any]]) -> str:
 
 async def render_giveaway(giveaway: dict[str, Any]) -> str:
     participants = await count_participants(giveaway["giveaway_id"])
+    status_label = giveaway_status_label(giveaway["status"])
+    if giveaway["status"] == STATUS_RUNNING and giveaway_starts_at(giveaway) > utc_now():
+        status_label = "待报名"
     text = [
         f"<b>抽奖 #{escape(giveaway['giveaway_id'])}</b>",
-        f"<b>状态:</b>{escape(giveaway_status_label(giveaway['status']))}",
+        f"<b>状态:</b>{escape(status_label)}",
         f"<b>标题:</b>{escape(giveaway['title'])}",
     ]
     if giveaway.get("description"):
@@ -92,7 +115,9 @@ async def render_giveaway(giveaway: dict[str, Any]) -> str:
         [
             "<b>奖项:</b>",
             _format_prizes(giveaway["prizes"]),
-            f"<b>截止时间:</b>{as_utc(giveaway['ends_at']).strftime('%Y-%m-%d %H:%M UTC')}",
+            f"<b>报名开始:</b>{format_giveaway_time(giveaway_starts_at(giveaway))}",
+            f"<b>开奖时间:</b>{format_giveaway_time(giveaway['ends_at'])}",
+            "<b>时区:</b>北京时间",
             f"<b>参与人数:</b>{participants}",
         ]
     )
@@ -120,6 +145,23 @@ def join_markup(giveaway_id: str) -> InlineKeyboardMarkup:
     )
 
 
+async def refresh_giveaway_message(giveaway: dict[str, Any]) -> bool:
+    message_id = giveaway.get("message_id")
+    if not message_id:
+        return True
+    try:
+        await app.edit_message_text(
+            int(giveaway["chat_id"]),
+            int(message_id),
+            await render_giveaway(giveaway),
+            parse_mode=ParseMode.HTML,
+            reply_markup=join_markup(giveaway["giveaway_id"]),
+        )
+    except Exception:
+        return False
+    return True
+
+
 def pick_winners(
     participants: list[dict[str, Any]], prizes: list[dict[str, Any]]
 ) -> list[dict[str, Any]]:
@@ -220,6 +262,11 @@ async def join_giveaway(
             return "missing", None
         if giveaway["status"] != STATUS_RUNNING:
             return "closed", giveaway
+        now = utc_now()
+        if giveaway_starts_at(giveaway) > now:
+            return "not_started", giveaway
+        if as_utc(giveaway["ends_at"]) <= now:
+            return "ended", giveaway
         existing = await get_participant(giveaway_id, user_id)
         if existing:
             return "duplicate", giveaway
@@ -291,6 +338,8 @@ async def join_giveaway(
                 key_suffix=f"join-{result}",
                 idempotency_key=refund_key,
             )
+        if result == "ok":
+            await refresh_giveaway_message(giveaway)
     return result, giveaway
 
 
@@ -532,6 +581,7 @@ async def remove_and_optionally_refund_participant(
             display_name=participant.get("display_name"),
             key_suffix="participant-removed",
         )
+    await refresh_giveaway_message(giveaway)
     return True
 
 

+ 45 - 3
wbb/utils/dbgiveaway.py

@@ -105,6 +105,7 @@ async def create_giveaway(
     description: str,
     prizes: list[dict[str, Any]],
     ends_at: datetime,
+    starts_at: datetime | None = None,
     minimum_points: int = 0,
     entry_cost: int = 0,
     participation_reward: int = 0,
@@ -113,11 +114,14 @@ async def create_giveaway(
     title = title.strip()
     if not title:
         raise ValueError("必须填写抽奖标题。")
+    now = utc_now()
+    normalized_starts_at = as_utc(starts_at) if starts_at else now
     normalized_ends_at = as_utc(ends_at)
-    if normalized_ends_at <= utc_now():
-        raise ValueError("抽奖截止时间必须晚于当前时间。")
+    if normalized_ends_at <= now:
+        raise ValueError("开奖时间必须晚于当前时间。")
+    if normalized_starts_at >= normalized_ends_at:
+        raise ValueError("报名开始时间必须早于开奖时间。")
     normalized_prizes = normalize_prizes(prizes)
-    now = utc_now()
     for _ in range(20):
         giveaway_id = token_hex(3)
         doc = {
@@ -135,11 +139,14 @@ async def create_giveaway(
             "status": STATUS_RUNNING,
             "created_at": now,
             "updated_at": now,
+            "starts_at": normalized_starts_at,
             "ends_at": normalized_ends_at,
             "message_id": None,
             "winners": [],
             "rerolls": [],
         }
+        if normalized_starts_at <= now:
+            doc["start_announced_at"] = now
         try:
             await giveawaysdb.insert_one(doc)
             return doc
@@ -251,6 +258,38 @@ async def list_due_giveaways(limit: int = 20) -> list[dict[str, Any]]:
     return [doc async for doc in cursor]
 
 
+async def list_unannounced_started_giveaways(limit: int = 20) -> list[dict[str, Any]]:
+    await ensure_giveaway_indexes()
+    now = utc_now()
+    cursor = (
+        giveawaysdb.find(
+            {
+                "bot_id": BOT_PROFILE_ID,
+                "status": STATUS_RUNNING,
+                "starts_at": {"$lte": now},
+                "ends_at": {"$gt": now},
+                "start_announced_at": {"$exists": False},
+            }
+        )
+        .sort("starts_at", ASCENDING)
+        .limit(limit)
+    )
+    return [doc async for doc in cursor]
+
+
+async def mark_giveaway_start_announced(giveaway_id: str) -> None:
+    await ensure_giveaway_indexes()
+    now = utc_now()
+    await giveawaysdb.update_one(
+        {
+            "giveaway_id": normalize_giveaway_id(giveaway_id),
+            "status": STATUS_RUNNING,
+            "start_announced_at": {"$exists": False},
+        },
+        {"$set": {"start_announced_at": now, "updated_at": now}},
+    )
+
+
 async def list_pending_cancellations(limit: int = 20) -> list[dict[str, Any]]:
     await ensure_giveaway_indexes()
     cursor = (
@@ -482,6 +521,9 @@ async def add_participant(
         return "missing"
     if giveaway["status"] != STATUS_RUNNING:
         return "closed"
+    starts_at = giveaway.get("starts_at") or giveaway.get("created_at")
+    if starts_at and as_utc(starts_at) > utc_now():
+        return "not_started"
     if as_utc(giveaway["ends_at"]) <= utc_now():
         return "ended"
     if await is_giveaway_banned(giveaway["chat_id"], user_id):