Răsfoiți Sursa

feat: add giveaway editing

AI-Co-Authored-By: Codex
chendeben 1 săptămână în urmă
părinte
comite
d40c91d65c

+ 1 - 0
admin-web/src/pages/AuditLogs.tsx

@@ -95,6 +95,7 @@ export default function AuditLogsPage() {
                 'points.adjust.deduct',
                 'points.adjust.set',
                 'giveaway.create',
+                'giveaway.update',
                 'giveaway.finish',
                 'giveaway.cancel',
                 'giveaway.reroll',

+ 138 - 3
admin-web/src/pages/Giveaways.tsx

@@ -18,7 +18,7 @@ import {
 } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
 import dayjs from 'dayjs';
-import { Download, Plus, RefreshCw, RotateCcw, Trash2, Trophy, UserX } from 'lucide-react';
+import { Download, Pencil, Plus, RefreshCw, RotateCcw, Trash2, Trophy, UserX } from 'lucide-react';
 import { useLocation } from '@umijs/max';
 import { useEffect, useState } from 'react';
 
@@ -62,6 +62,9 @@ export default function GiveawaysPage() {
   const [loading, setLoading] = useState(false);
   const [createOpen, setCreateOpen] = useState(false);
   const [createForm] = Form.useForm();
+  const [editing, setEditing] = useState<Giveaway | null>(null);
+  const [editSaving, setEditSaving] = useState(false);
+  const [editForm] = Form.useForm();
   const [selected, setSelected] = useState<Giveaway | null>(null);
   const [participants, setParticipants] = useState<Participant[]>([]);
   const [banOpen, setBanOpen] = useState(false);
@@ -117,6 +120,16 @@ export default function GiveawaysPage() {
     if (selected) await loadParticipants(selected);
   };
 
+  const openEdit = (giveaway: Giveaway) => {
+    editForm.setFieldsValue({
+      title: giveaway.title,
+      description: giveaway.description || '',
+      starts_at: dayjs(giveaway.starts_at || giveaway.created_at),
+      ends_at: dayjs(giveaway.ends_at),
+    });
+    setEditing(giveaway);
+  };
+
   useEffect(() => {
     apiRequest<Paged<ChatSummary>>('/chats?page_size=100').then((result) => {
       const accessibleChats = result.items.filter((chat) => chat.accessible);
@@ -188,12 +201,19 @@ export default function GiveawaysPage() {
       title: '操作',
       key: 'actions',
       fixed: 'right',
-      width: 270,
+      width: 330,
       render: (_, record) => (
         <Space size={4} wrap>
           <Button type="link" onClick={() => void loadParticipants(record)}>参与者</Button>
           {record.status === 'running' ? (
             <>
+              <Button
+                type="link"
+                icon={<Pencil size={15} />}
+                onClick={() => openEdit(record)}
+              >
+                编辑
+              </Button>
               <Button
                 type="link"
                 icon={<Trophy size={15} />}
@@ -357,7 +377,7 @@ export default function GiveawaysPage() {
           loading={loading}
           pagination={{ current: data.page, pageSize: data.page_size, total: data.total, showSizeChanger: false }}
           onChange={(pagination) => void load(pagination.current || 1)}
-          scroll={{ x: 1100 }}
+          scroll={{ x: 1180 }}
         />
       </Surface>
       <Modal
@@ -470,6 +490,121 @@ export default function GiveawaysPage() {
           </Space>
         </Form>
       </Modal>
+      <Modal
+        title={editing ? `编辑抽奖 · #${editing.giveaway_id}` : '编辑抽奖'}
+        open={Boolean(editing)}
+        width={640}
+        okText="保存并刷新群消息"
+        confirmLoading={editSaving}
+        cancelButtonProps={{ disabled: editSaving }}
+        maskClosable={!editSaving}
+        onCancel={() => {
+          setEditing(null);
+          editForm.resetFields();
+        }}
+        onOk={() => editForm.submit()}
+        destroyOnHidden
+      >
+        <Form
+          form={editForm}
+          layout="vertical"
+          onFinish={async (values) => {
+            if (!editing) return;
+            const startsAt = dayjs(values.starts_at);
+            const endsAt = dayjs(values.ends_at);
+            setEditSaving(true);
+            try {
+              await apiRequest<Giveaway>(
+                `/giveaways/${editing.giveaway_id}`,
+                jsonOptions('PATCH', {
+                  title: values.title,
+                  description: values.description || '',
+                  starts_at: startsAt.toISOString(),
+                  ends_at: endsAt.toISOString(),
+                  expected_updated_at: editing.updated_at,
+                  confirm: true,
+                }),
+              );
+              setEditing(null);
+              editForm.resetFields();
+              message.success('抽奖已更新,群内消息已刷新');
+              await load(data.page);
+            } catch (error) {
+              message.error(error instanceof Error ? error.message : '抽奖更新失败');
+            } finally {
+              setEditSaving(false);
+            }
+          }}
+        >
+          <Form.Item
+            name="title"
+            label="标题"
+            rules={[{ required: true, whitespace: true, message: '请输入抽奖标题' }]}
+          >
+            <Input maxLength={200} />
+          </Form.Item>
+          <Form.Item
+            name="description"
+            label="说明"
+            extra="保存后会同步更新群内原抽奖消息"
+          >
+            <Input.TextArea rows={4} maxLength={4000} showCount />
+          </Form.Item>
+          <Space wrap align="start" size={16} style={{ display: 'flex' }}>
+            <Form.Item
+              name="starts_at"
+              label="报名开始时间"
+              extra={
+                editing && !dayjs(editing.starts_at || editing.created_at).isAfter(dayjs())
+                  ? '报名已经开始,此时间不可修改'
+                  : '按浏览器当前时区输入'
+              }
+              rules={[{ required: true, message: '请选择报名开始时间' }]}
+              style={{ flex: 1, minWidth: 240 }}
+            >
+              <DatePicker
+                disabled={Boolean(
+                  editing && !dayjs(editing.starts_at || editing.created_at).isAfter(dayjs()),
+                )}
+                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>
+          <Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
+            奖项与积分规则不可编辑,避免报名后改变抽奖条件。
+          </Typography.Paragraph>
+        </Form>
+      </Modal>
       <Drawer
         title={selected ? `参与者 · #${selected.giveaway_id}` : '参与者'}
         open={Boolean(selected)}

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

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

+ 30 - 2
admin-web/tests/unit/Giveaways.test.tsx

@@ -32,14 +32,16 @@ beforeEach(() => {
           giveaway_id: 'draw-1',
           chat_id: '-100123',
           title: '七月抽奖',
+          description: '原说明',
           status: 'running',
           prizes: [{ name: '一等奖', count: 1, points_reward: 50 }],
           minimum_points: 20,
           entry_cost: 5,
           participation_reward: 2,
-          starts_at: '2026-07-24T00:00:00Z',
-          ends_at: '2026-07-25T08:00:00Z',
+          starts_at: '2099-07-24T00:00:00Z',
+          ends_at: '2099-07-25T08:00:00Z',
           created_at: '2026-07-24T08:00:00Z',
+          updated_at: '2026-07-24T08:00:01Z',
         }],
         total: 1,
         page: 1,
@@ -88,6 +90,32 @@ it('展示抽奖积分配置并在危险操作确认后开奖', async () => {
   });
 });
 
+it('进行中的抽奖可以编辑并刷新群消息', async () => {
+  render(<GiveawaysPage />);
+  await screen.findByText('七月抽奖');
+
+  fireEvent.click(screen.getByRole('button', { name: /编\s*辑/ }));
+  expect(await screen.findByText('奖项与积分规则不可编辑,避免报名后改变抽奖条件。')).toBeInTheDocument();
+  fireEvent.change(screen.getByLabelText('标题'), { target: { value: '七月抽奖更新版' } });
+  fireEvent.change(screen.getByLabelText('说明'), { target: { value: '有效期一个月' } });
+  fireEvent.click(screen.getByRole('button', { name: /保\s*存\s*并\s*刷\s*新\s*群\s*消\s*息/ }));
+
+  await waitFor(() => {
+    const call = vi.mocked(apiRequest).mock.calls.find(
+      ([path, options]) => path === '/giveaways/draw-1' && options?.method === 'PATCH',
+    );
+    expect(call).toBeTruthy();
+    expect(JSON.parse(String(call?.[1]?.body))).toEqual({
+      title: '七月抽奖更新版',
+      description: '有效期一个月',
+      starts_at: '2099-07-24T00:00:00.000Z',
+      ends_at: '2099-07-25T08:00:00.000Z',
+      expected_updated_at: '2026-07-24T08:00:01Z',
+      confirm: true,
+    });
+  });
+});
+
 it('创建表单包含积分联动字段且发布前必须二次确认', async () => {
   mockLocation.search = '?chat_id=-100123';
   render(<GiveawaysPage />);

+ 58 - 0
tests/test_admin_api_auth.py

@@ -1,9 +1,11 @@
 from __future__ import annotations
 
 from datetime import UTC, datetime, timedelta
+from types import SimpleNamespace
 
 from aiohttp import CookieJar
 from aiohttp.test_utils import TestClient, TestServer
+from pyrogram.enums import ChatMemberStatus
 
 
 async def test_login_forced_password_change_csrf_and_session(app_modules):
@@ -65,6 +67,62 @@ async def test_login_forced_password_change_csrf_and_session(app_modules):
         assert dashboard.status == 200
         assert "counts" in (await dashboard.json())["data"]
 
+        giveaway_db = app_modules.load("wbb.utils.dbgiveaway")
+        now = datetime.now(UTC)
+        giveaway = await giveaway_db.create_giveaway(
+            chat_id=-100,
+            creator_id=1,
+            creator_name="Admin",
+            title="Original",
+            description="",
+            prizes=[{"name": "Winner", "count": 1}],
+            starts_at=now + timedelta(hours=1),
+            ends_at=now + timedelta(hours=2),
+        )
+        await giveaway_db.attach_giveaway_message(
+            giveaway["giveaway_id"], -100, 88
+        )
+        giveaway = await giveaway_db.get_giveaway(giveaway["giveaway_id"])
+        app_modules.app.members[(-100, 999)] = SimpleNamespace(
+            status=ChatMemberStatus.OWNER
+        )
+        expected_updated_at = giveaway["updated_at"]
+        if expected_updated_at.tzinfo is None:
+            expected_updated_at = expected_updated_at.replace(tzinfo=UTC)
+        update_body = {
+            "title": "Updated",
+            "description": "Valid for one month",
+            "starts_at": (now + timedelta(hours=1)).isoformat(),
+            "ends_at": (now + timedelta(hours=3)).isoformat(),
+            "expected_updated_at": expected_updated_at.isoformat(),
+            "confirm": True,
+        }
+        update_without_csrf = await client.patch(
+            f"/api/admin/v1/giveaways/{giveaway['giveaway_id']}",
+            json=update_body,
+        )
+        assert update_without_csrf.status == 403
+        update_without_confirmation = await client.patch(
+            f"/api/admin/v1/giveaways/{giveaway['giveaway_id']}",
+            headers={"X-CSRF-Token": changed_data["csrf_token"]},
+            json={**update_body, "confirm": False},
+        )
+        assert update_without_confirmation.status == 409
+        updated = await client.patch(
+            f"/api/admin/v1/giveaways/{giveaway['giveaway_id']}",
+            headers={"X-CSRF-Token": changed_data["csrf_token"]},
+            json=update_body,
+        )
+        assert updated.status == 200
+        updated_data = (await updated.json())["data"]
+        assert updated_data["title"] == "Updated"
+        assert updated_data["description"] == "Valid for one month"
+        assert app_modules.app.edited_messages[-1][:2] == (-100, 88)
+        audit = await app_modules.wbb.db.admin_audit_logs.find_one(
+            {"action": "giveaway.update", "target_id": giveaway["giveaway_id"]}
+        )
+        assert audit["success"] is True
+
         await app_modules.wbb.db.managed_chats.insert_one(
             {
                 "bot_id": "primary",

+ 90 - 0
tests/test_giveaway_points.py

@@ -143,6 +143,96 @@ async def test_registration_start_must_precede_draw_time(app_modules):
         )
 
 
+async def test_running_giveaway_update_refreshes_message_and_rejects_stale_write(
+    app_modules,
+):
+    giveaway_db = app_modules.load("wbb.utils.dbgiveaway")
+    service = app_modules.load("wbb.services.giveaways")
+    now = datetime.now(UTC)
+    giveaway = await giveaway_db.create_giveaway(
+        chat_id=-100,
+        creator_id=1,
+        creator_name="Admin",
+        title="Original title",
+        description="Original description",
+        prizes=[{"name": "Winner", "count": 1, "points_reward": 5}],
+        starts_at=now + timedelta(hours=1),
+        ends_at=now + timedelta(hours=2),
+        minimum_points=20,
+        entry_cost=5,
+        participation_reward=2,
+    )
+    await giveaway_db.attach_giveaway_message(giveaway["giveaway_id"], -100, 99)
+    current = await giveaway_db.get_giveaway(giveaway["giveaway_id"])
+    new_starts_at = now + timedelta(hours=2)
+    new_ends_at = now + timedelta(hours=3)
+
+    updated = await service.update_and_refresh_giveaway(
+        giveaway["giveaway_id"],
+        title="Updated title",
+        description="Valid for one month",
+        starts_at=new_starts_at,
+        ends_at=new_ends_at,
+        expected_updated_at=current["updated_at"],
+    )
+
+    assert updated["title"] == "Updated title"
+    assert updated["description"] == "Valid for one month"
+    assert int(giveaway_db.as_utc(updated["starts_at"]).timestamp() * 1000) == int(
+        new_starts_at.timestamp() * 1000
+    )
+    assert int(giveaway_db.as_utc(updated["ends_at"]).timestamp() * 1000) == int(
+        new_ends_at.timestamp() * 1000
+    )
+    assert updated["prizes"] == giveaway["prizes"]
+    assert updated["minimum_points"] == 20
+    assert updated["entry_cost"] == 5
+    assert updated["participation_reward"] == 2
+    assert app_modules.app.edited_messages[-1][:2] == (-100, 99)
+    assert "Updated title" in app_modules.app.edited_messages[-1][2]
+    assert "Valid for one month" in app_modules.app.edited_messages[-1][2]
+
+    with pytest.raises(service.GiveawayServiceError) as error:
+        await service.update_and_refresh_giveaway(
+            giveaway["giveaway_id"],
+            title="Stale title",
+            description="Stale description",
+            starts_at=new_starts_at,
+            ends_at=new_ends_at,
+            expected_updated_at=current["updated_at"],
+        )
+    assert error.value.code == "giveaway_stale"
+
+
+async def test_running_giveaway_cannot_move_registration_start_after_it_begins(
+    app_modules,
+):
+    giveaway_db = app_modules.load("wbb.utils.dbgiveaway")
+    service = app_modules.load("wbb.services.giveaways")
+    now = datetime.now(UTC)
+    giveaway = await giveaway_db.create_giveaway(
+        chat_id=-100,
+        creator_id=1,
+        creator_name="Admin",
+        title="Open giveaway",
+        description="",
+        prizes=[{"name": "Winner", "count": 1}],
+        starts_at=now - timedelta(minutes=1),
+        ends_at=now + timedelta(hours=1),
+    )
+
+    with pytest.raises(service.GiveawayServiceError) as error:
+        await service.update_and_refresh_giveaway(
+            giveaway["giveaway_id"],
+            title="Open giveaway",
+            description="Updated",
+            starts_at=now + timedelta(minutes=1),
+            ends_at=now + timedelta(hours=2),
+            expected_updated_at=giveaway["updated_at"],
+        )
+    assert error.value.code == "giveaway_registration_started"
+
+
 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")

+ 37 - 0
wbb/admin/api.py

@@ -74,6 +74,7 @@ from wbb.services.giveaways import (
     finish_and_publish_giveaway,
     remove_and_optionally_refund_participant,
     reroll_giveaway,
+    update_and_refresh_giveaway,
 )
 from wbb.services.point_settings import apply_point_rules
 from wbb.services.technician_reviews import (
@@ -962,6 +963,7 @@ class AdminApi:
         router.add_get(f"{API_PREFIX}/giveaways", self.giveaways)
         router.add_post(f"{API_PREFIX}/giveaways", self.giveaway_create)
         router.add_get(f"{API_PREFIX}/giveaways/{{giveaway_id}}", self.giveaway_detail)
+        router.add_patch(f"{API_PREFIX}/giveaways/{{giveaway_id}}", self.giveaway_update)
         router.add_post(f"{API_PREFIX}/giveaways/{{giveaway_id}}/finish", self.giveaway_finish)
         router.add_post(f"{API_PREFIX}/giveaways/{{giveaway_id}}/cancel", self.giveaway_cancel)
         router.add_post(f"{API_PREFIX}/giveaways/{{giveaway_id}}/reroll", self.giveaway_reroll)
@@ -2859,6 +2861,41 @@ class AdminApi:
             raise ApiProblem("giveaway_not_found", "未找到该抽奖。", status=404)
         return success(giveaway)
 
+    async def giveaway_update(self, request: web.Request) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        giveaway_id = request.match_info["giveaway_id"]
+        giveaway = await get_giveaway(giveaway_id)
+        if not giveaway:
+            raise ApiProblem("giveaway_not_found", "未找到该抽奖。", status=404)
+        await ensure_permission(int(giveaway["chat_id"]), "can_change_info")
+        starts_at = parse_datetime(body.get("starts_at"), "starts_at")
+        ends_at = parse_datetime(body.get("ends_at"), "ends_at")
+        expected_updated_at = parse_datetime(
+            body.get("expected_updated_at"), "expected_updated_at"
+        )
+        set_audit(
+            request,
+            "giveaway.update",
+            chat_id=int(giveaway["chat_id"]),
+            target_id=giveaway_id,
+            summary=str(body.get("title") or ""),
+            metadata={
+                "starts_at": starts_at,
+                "ends_at": ends_at,
+            },
+        )
+        return success(
+            await update_and_refresh_giveaway(
+                giveaway_id,
+                title=str(body.get("title") or ""),
+                description=str(body.get("description") or ""),
+                starts_at=starts_at,
+                ends_at=ends_at,
+                expected_updated_at=expected_updated_at,
+            )
+        )
+
     async def giveaway_finish(self, request: web.Request) -> web.Response:
         body = await json_body(request)
         require_confirmation(body)

+ 68 - 0
wbb/services/giveaways.py

@@ -35,6 +35,7 @@ from wbb.utils.dbgiveaway import (
     record_reroll,
     remove_participant,
     save_pending_winners,
+    update_running_giveaway,
     utc_now,
 )
 from wbb.utils.dbpoints import (
@@ -61,6 +62,10 @@ class GiveawayServiceError(RuntimeError):
         self.status = status
 
 
+def _mongo_datetime_millis(value: datetime) -> int:
+    return int(as_utc(value).timestamp() * 1000)
+
+
 def parse_giveaway_time(raw: str) -> datetime | None:
     try:
         local_time = datetime.strptime(raw.strip(), "%Y-%m-%d %H:%M")
@@ -162,6 +167,69 @@ async def refresh_giveaway_message(giveaway: dict[str, Any]) -> bool:
     return True
 
 
+async def update_and_refresh_giveaway(
+    giveaway_id: str,
+    *,
+    title: str,
+    description: str,
+    starts_at: datetime,
+    ends_at: datetime,
+    expected_updated_at: datetime,
+) -> dict[str, Any]:
+    async with _giveaway_locks[giveaway_id]:
+        giveaway = await get_giveaway(giveaway_id)
+        if not giveaway:
+            raise GiveawayServiceError(
+                "giveaway_not_found", "未找到该抽奖。", status=404
+            )
+        if giveaway["status"] != STATUS_RUNNING:
+            raise GiveawayServiceError(
+                "giveaway_not_running", "只有进行中的抽奖可以编辑。", status=409
+            )
+        if _mongo_datetime_millis(giveaway["updated_at"]) != _mongo_datetime_millis(
+            expected_updated_at
+        ):
+            raise GiveawayServiceError(
+                "giveaway_stale",
+                "抽奖已被其他操作更新,请刷新页面后重试。",
+                status=409,
+            )
+
+        current_starts_at = giveaway_starts_at(giveaway)
+        if current_starts_at <= utc_now() and _mongo_datetime_millis(
+            starts_at
+        ) != _mongo_datetime_millis(current_starts_at):
+            raise GiveawayServiceError(
+                "giveaway_registration_started",
+                "报名开始后不能修改报名开始时间。",
+                status=409,
+            )
+        try:
+            updated = await update_running_giveaway(
+                giveaway_id,
+                title=title,
+                description=description,
+                starts_at=starts_at,
+                ends_at=ends_at,
+                expected_updated_at=giveaway["updated_at"],
+            )
+        except ValueError as exc:
+            raise GiveawayServiceError("invalid_giveaway", str(exc)) from exc
+        if not updated:
+            raise GiveawayServiceError(
+                "giveaway_stale",
+                "抽奖状态或内容已变化,请刷新页面后重试。",
+                status=409,
+            )
+        if not await refresh_giveaway_message(updated):
+            raise GiveawayServiceError(
+                "giveaway_message_refresh_failed",
+                "抽奖内容已保存,但群消息刷新失败,请刷新页面后重试保存。",
+                status=502,
+            )
+        return updated
+
+
 def pick_winners(
     participants: list[dict[str, Any]], prizes: list[dict[str, Any]]
 ) -> list[dict[str, Any]]:

+ 49 - 1
wbb/utils/dbgiveaway.py

@@ -2,7 +2,7 @@ from __future__ import annotations
 
 import re
 from asyncio import Lock
-from datetime import UTC, datetime
+from datetime import UTC, datetime, timedelta
 from secrets import token_hex
 from typing import Any
 
@@ -181,6 +181,54 @@ async def get_giveaway(giveaway_id: str) -> dict[str, Any] | None:
     )
 
 
+async def update_running_giveaway(
+    giveaway_id: str,
+    *,
+    title: str,
+    description: str,
+    starts_at: datetime,
+    ends_at: datetime,
+    expected_updated_at: datetime,
+) -> dict[str, Any] | None:
+    await ensure_giveaway_indexes()
+    normalized_title = title.strip()
+    normalized_description = description.strip()
+    normalized_starts_at = as_utc(starts_at)
+    normalized_ends_at = as_utc(ends_at)
+    now = utc_now()
+    previous_updated_at = as_utc(expected_updated_at)
+    if int(now.timestamp() * 1000) <= int(previous_updated_at.timestamp() * 1000):
+        now = previous_updated_at + timedelta(milliseconds=1)
+    if not normalized_title:
+        raise ValueError("必须填写抽奖标题。")
+    if len(normalized_title) > 200:
+        raise ValueError("抽奖标题不能超过 200 个字符。")
+    if len(normalized_description) > 4000:
+        raise ValueError("抽奖说明不能超过 4000 个字符。")
+    if normalized_ends_at <= now:
+        raise ValueError("开奖时间必须晚于当前时间。")
+    if normalized_starts_at >= normalized_ends_at:
+        raise ValueError("报名开始时间必须早于开奖时间。")
+    return await giveawaysdb.find_one_and_update(
+        {
+            "giveaway_id": normalize_giveaway_id(giveaway_id),
+            "bot_id": BOT_PROFILE_ID,
+            "status": STATUS_RUNNING,
+            "updated_at": expected_updated_at,
+        },
+        {
+            "$set": {
+                "title": normalized_title,
+                "description": normalized_description,
+                "starts_at": normalized_starts_at,
+                "ends_at": normalized_ends_at,
+                "updated_at": now,
+            }
+        },
+        return_document=ReturnDocument.AFTER,
+    )
+
+
 async def list_running_giveaways(
     *, chat_id: int | None = None, creator_id: int | None = None, limit: int = 20
 ) -> list[dict[str, Any]]: