Parcourir la source

feat: add configurable recurring giveaways and risk rules

AI-Co-Authored-By: Codex
chendeben il y a 1 semaine
Parent
commit
d66d7dd323

+ 172 - 0
admin-web/src/pages/ChatDetail.tsx

@@ -72,6 +72,22 @@ interface InviteLink {
   revoked?: boolean;
 }
 
+interface ExternalRiskSnapshot {
+  snapshot_id: string;
+  source_id: string;
+  status: string;
+  entry_count: number;
+  sha256: string;
+  created_at: string;
+  license: string;
+}
+
+interface ExternalRiskCatalog {
+  sources: Record<string, { name: string; repository: string; license: string; kind: string }>;
+  snapshots: ExternalRiskSnapshot[];
+  active: Array<{ source_id: string; active_snapshot_id: string }>;
+}
+
 type RiskMemberAction = 'none' | 'warn' | 'mute' | 'kick' | 'ban';
 
 const riskMemberActionLabels: Record<RiskMemberAction, string> = {
@@ -908,8 +924,30 @@ function AutomationTab({ chatId, initial }: { chatId: string; initial: Automatio
   const [editingRiskRuleId, setEditingRiskRuleId] = useState<string | null>(null);
   const [riskForm] = Form.useForm();
   const selectedRiskMemberAction = Form.useWatch('member_action', riskForm) as RiskMemberAction | undefined;
+  const [externalCatalog, setExternalCatalog] = useState<ExternalRiskCatalog | null>(null);
+  const [externalMode, setExternalMode] = useState<'off' | 'observe' | 'delete'>('observe');
+  const [reviewSnapshot, setReviewSnapshot] = useState<ExternalRiskSnapshot | null>(null);
+  const [reviewEntries, setReviewEntries] = useState<{ items: Array<{ value: string }>; total: number; page: number; page_size: number }>({ items: [], total: 0, page: 1, page_size: 50 });
+  const [reviewQuery, setReviewQuery] = useState('');
+  const loadReviewEntries = async (snapshotId: string, page = 1, query = '') => {
+    const params = new URLSearchParams({ page: String(page), page_size: '50', query });
+    setReviewEntries(await apiRequest(`/external-risk/snapshots/${snapshotId}/entries?${params}`));
+  };
+  const [interactionForm] = Form.useForm();
+  const loadExternal = async () => {
+    const [catalog, policy] = await Promise.all([
+      apiRequest<ExternalRiskCatalog>('/external-risk/sources'),
+      apiRequest<{ mode: 'off' | 'observe' | 'delete' }>(`/external-risk/chats/${chatId}`),
+    ]);
+    setExternalCatalog(catalog);
+    setExternalMode(policy.mode);
+  };
 
   useEffect(() => {
+    void loadExternal();
+    void apiRequest<{ cleanup_seconds: number; commands: Record<string, boolean> }>(
+      `/chats/${chatId}/interaction-settings`,
+    ).then((settings) => interactionForm.setFieldsValue(settings));
     form.setFieldsValue({
       ...initial,
       welcome_enabled: initial.welcome?.enabled,
@@ -1122,8 +1160,142 @@ function AutomationTab({ chatId, initial }: { chatId: string; initial: Automatio
             scroll={{ x: 840 }}
           />
         </Surface>
+        <div style={{ height: 16 }} />
+        <Surface title="开源反广告规则快照">
+          <Space wrap style={{ marginBottom: 16 }}>
+            <Select
+              value={externalMode}
+              style={{ width: 170 }}
+              options={[
+                { value: 'off', label: '关闭' },
+                { value: 'observe', label: '仅观察' },
+                { value: 'delete', label: '命中后删消息' },
+              ]}
+              onChange={(mode) => {
+                Modal.confirm({
+                  title: `确认切换为“${mode === 'delete' ? '删消息' : mode === 'observe' ? '仅观察' : '关闭'}”?`,
+                  content: '外部规则不会自动封禁成员。',
+                  onOk: async () => {
+                    await apiRequest(`/external-risk/chats/${chatId}`, jsonOptions('PATCH', { mode, confirm: true }));
+                    setExternalMode(mode);
+                  },
+                });
+              }}
+            />
+            <Typography.Text type="secondary">规则先下载为快照,审核后发布;新版不会自动生效。</Typography.Text>
+          </Space>
+          <Table
+            size="small"
+            rowKey="source_id"
+            pagination={false}
+            dataSource={Object.entries(externalCatalog?.sources || {}).map(([source_id, source]) => ({ source_id, ...source }))}
+            columns={[
+              { title: '来源', render: (_, item) => <a href={item.repository} target="_blank" rel="noreferrer">{item.name}</a> },
+              { title: '许可证', dataIndex: 'license' },
+              { title: '当前版本', render: (_, item) => {
+                const activeId = externalCatalog?.active.find((state) => state.source_id === item.source_id)?.active_snapshot_id;
+                const snapshot = externalCatalog?.snapshots.find((value) => value.snapshot_id === activeId);
+                return snapshot ? `${snapshot.entry_count} 条 · ${snapshot.sha256.slice(0, 10)}` : '未发布';
+              } },
+              { title: '操作', render: (_, item) => <Button htmlType="button" size="small" onClick={() => Modal.confirm({
+                title: `下载 ${item.name} 的规则快照?`,
+                content: item.kind === 'phrase' ? '短语较宽泛,默认只作审核候选。' : '下载后仍需单独审核发布。',
+                onOk: async () => {
+                  await apiRequest(`/external-risk/sources/${item.source_id}/stage`, jsonOptions('POST', { confirm: true }));
+                  await loadExternal();
+                },
+              })}>下载待审核版本</Button> },
+            ]}
+          />
+          <Divider orientation="left">版本审核与回滚</Divider>
+          <Table
+            size="small"
+            rowKey="snapshot_id"
+            pagination={{ pageSize: 5 }}
+            dataSource={externalCatalog?.snapshots || []}
+            columns={[
+              { title: '来源', dataIndex: 'source_id' },
+              { title: '条数', dataIndex: 'entry_count' },
+              { title: 'SHA-256', render: (_, item) => <Typography.Text code>{item.sha256.slice(0, 16)}</Typography.Text> },
+              { title: '状态', render: (_, item) => externalCatalog?.active.some((state) => state.active_snapshot_id === item.snapshot_id) ? '当前使用' : item.status },
+              { title: '操作', render: (_, item) => <Space>
+                <Button htmlType="button" size="small" onClick={() => {
+                  setReviewSnapshot(item);
+                  setReviewQuery('');
+                  void loadReviewEntries(item.snapshot_id);
+                }}>查看条目</Button>
+                <Button htmlType="button" size="small" onClick={() => Modal.confirm({
+                  title: '确认发布此规则版本?',
+                  content: '发布或回滚仅切换本地快照;当前群处置方式由上方模式控制。',
+                  onOk: async () => {
+                    await apiRequest(`/external-risk/snapshots/${item.snapshot_id}/publish`, jsonOptions('POST', { confirm: true }));
+                    await loadExternal();
+                  },
+                })}>发布/回滚</Button>
+              </Space> },
+            ]}
+          />
+        </Surface>
         <Button type="primary" htmlType="submit" style={{ marginTop: 16 }}>保存自动化配置</Button>
       </Form>
+      <Drawer
+        title={reviewSnapshot ? `审核规则 · ${reviewSnapshot.source_id}` : '审核规则'}
+        open={Boolean(reviewSnapshot)}
+        onClose={() => setReviewSnapshot(null)}
+        width={600}
+      >
+        <Input.Search
+          value={reviewQuery}
+          onChange={(event) => setReviewQuery(event.target.value)}
+          onSearch={(query) => reviewSnapshot && void loadReviewEntries(reviewSnapshot.snapshot_id, 1, query)}
+          placeholder="搜索规则条目"
+          style={{ marginBottom: 16 }}
+        />
+        <Table
+          rowKey="value"
+          size="small"
+          dataSource={reviewEntries.items}
+          columns={[{ title: '规则条目', dataIndex: 'value' }]}
+          pagination={{
+            current: reviewEntries.page,
+            pageSize: reviewEntries.page_size,
+            total: reviewEntries.total,
+            onChange: (page) => reviewSnapshot && void loadReviewEntries(reviewSnapshot.snapshot_id, page, reviewQuery),
+          }}
+        />
+      </Drawer>
+      <Surface title="群内指令与消息清理">
+        <Form
+          form={interactionForm}
+          layout="vertical"
+          onFinish={async (values) => {
+            try {
+              await apiRequest(`/chats/${chatId}/interaction-settings`, jsonOptions('PATCH', {
+                ...values, confirm: true,
+              }));
+              message.success('群内指令配置已保存');
+            } catch (error) {
+              message.error(error instanceof Error ? error.message : '保存失败');
+            }
+          }}
+        >
+          <Form.Item name="cleanup_seconds" label="指令及回复自动清理(秒)" rules={[{ required: true }]}>
+            <InputNumber min={1} max={300} />
+          </Form.Item>
+          <Space wrap>
+            {[
+              ['giveaway', '抽奖/开奖'],
+              ['checkin', '签到'],
+              ['points', '积分/排行'],
+              ['history', '流水'],
+              ['shortcuts', '管理员快捷操作'],
+            ].map(([key, label]) => <Form.Item key={key} name={['commands', key]} label={label} valuePropName="checked">
+              <Switch />
+            </Form.Item>)}
+          </Space>
+          <Button type="primary" htmlType="submit">保存群内指令配置</Button>
+        </Form>
+      </Surface>
       <Drawer
         title={editingRiskRuleId ? '编辑风控规则' : '新增风控规则'}
         open={riskDrawer}

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

@@ -25,7 +25,7 @@ import { useEffect, useState } from 'react';
 import { PageHeader, Surface } from '@/components/Page';
 import { StatusTag } from '@/components/StatusTag';
 import { apiRequest, downloadFile, jsonOptions } from '@/services/api';
-import type { ChatSummary, Giveaway, Paged } from '@/types';
+import type { ChatSummary, Giveaway, GiveawayTemplate, Paged } from '@/types';
 import { userDisplayName } from '@/utils/users';
 import { giveawayDisplayStatus } from '@/utils/giveaways';
 
@@ -38,6 +38,11 @@ interface Participant {
   last_name?: string | null;
   display_name?: string | null;
   active?: boolean;
+  pending?: boolean;
+  ticket_count?: number;
+  paid_ticket_count?: number;
+  eligibility_status?: string;
+  eligibility_failed_chat_ids?: string[];
   entry_cost?: number;
   joined_at: string;
   removed_at?: string | null;
@@ -52,6 +57,15 @@ interface GiveawayBan {
   created_at: string;
 }
 
+interface TicketOrder {
+  order_id: string;
+  user_id: string;
+  quantity: number;
+  amount: number;
+  status: string;
+  created_at: string;
+}
+
 export default function GiveawaysPage() {
   const location = useLocation();
   const initialChat = new URLSearchParams(location.search).get('chat_id') || undefined;
@@ -67,11 +81,27 @@ export default function GiveawaysPage() {
   const [editForm] = Form.useForm();
   const [selected, setSelected] = useState<Giveaway | null>(null);
   const [participants, setParticipants] = useState<Paged<Participant>>({ items: [], total: 0, page: 1, page_size: 20 });
+  const [orders, setOrders] = useState<Paged<TicketOrder>>({ items: [], total: 0, page: 1, page_size: 20 });
   const [participantLoading, setParticipantLoading] = useState(false);
   const [banOpen, setBanOpen] = useState(false);
   const [bans, setBans] = useState<Paged<GiveawayBan>>({ items: [], total: 0, page: 1, page_size: 20 });
   const [banLoading, setBanLoading] = useState(false);
   const [banForm] = Form.useForm();
+  const [templates, setTemplates] = useState<GiveawayTemplate[]>([]);
+  const [templateOpen, setTemplateOpen] = useState(false);
+  const [editingTemplate, setEditingTemplate] = useState<GiveawayTemplate | null>(null);
+  const [templateForm] = Form.useForm();
+
+  const loadTemplates = async () => {
+    const result = await apiRequest<{ items: GiveawayTemplate[] }>('/giveaways/templates');
+    setTemplates(Array.isArray(result.items) ? result.items : []);
+  };
+
+  const targetPayload = (values: Record<string, unknown>) => ({
+    ...values,
+    eligibility_targets: ((values.eligibility_chat_ids || []) as string[]).map((chat_id) => ({ chat_id })),
+    eligibility_chat_ids: undefined,
+  });
 
   const load = async (page = 1) => {
     setLoading(true);
@@ -96,6 +126,9 @@ export default function GiveawaysPage() {
           `/giveaways/${giveaway.giveaway_id}/participants?page=${page}&page_size=20`,
         ),
       );
+      setOrders(await apiRequest<Paged<TicketOrder>>(
+        `/giveaways/${giveaway.giveaway_id}/orders?page=1&page_size=20`,
+      ));
     } catch (error) {
       message.error(error instanceof Error ? error.message : '参与者加载失败');
     } finally {
@@ -153,6 +186,7 @@ export default function GiveawaysPage() {
 
   useEffect(() => {
     void load(1);
+    void loadTemplates();
   }, [chatId, status]);
 
   const runDanger = (title: string, action: () => Promise<unknown>) => {
@@ -247,6 +281,18 @@ export default function GiveawaysPage() {
               </Button>
             </>
           ) : null}
+          {record.status === 'drawing' && record.eligibility_error ? (
+            <>
+              <Button type="link" onClick={() => runDanger(
+                '确认立即重试开奖资格核验?',
+                () => apiRequest(`/giveaways/${record.giveaway_id}/finish`, jsonOptions('POST', { confirm: true })),
+              )}>立即重试</Button>
+              <Button danger type="link" onClick={() => runDanger(
+                '资格核验异常,确认取消该期并退还奖票积分?',
+                () => apiRequest(`/giveaways/${record.giveaway_id}/cancel`, jsonOptions('POST', { confirm: true })),
+              )}>取消并退款</Button>
+            </>
+          ) : null}
           {record.status === 'finished' ? (
             <Button
               type="link"
@@ -273,7 +319,9 @@ export default function GiveawaysPage() {
     },
     { title: '用户 ID', dataIndex: 'user_id', width: 140, className: 'numeric' },
     { title: '报名消耗', dataIndex: 'entry_cost', width: 100 },
-    { title: '状态', key: 'state', width: 100, render: (_, item) => <Tag color={item.active === false ? 'default' : 'success'}>{item.active === false ? '已移除' : '有效'}</Tag> },
+    { title: '有效奖票', width: 100, render: (_, item) => item.paid_ticket_count ?? item.ticket_count ?? 1 },
+    { title: '开奖资格', width: 110, render: (_, item) => item.eligibility_status || '待复核' },
+    { title: '状态', key: 'state', width: 100, render: (_, item) => <Tag color={item.active === false ? 'default' : item.pending ? 'warning' : 'success'}>{item.active === false ? '已移除' : item.pending ? '待付款' : '有效'}</Tag> },
     {
       title: '操作',
       width: 220,
@@ -363,9 +411,46 @@ export default function GiveawaysPage() {
               资格封禁
             </Button>
             <Button type="primary" icon={<Plus size={16} />} onClick={() => setCreateOpen(true)}>创建抽奖</Button>
+            <Button onClick={() => {
+              setEditingTemplate(null);
+              templateForm.resetFields();
+              setTemplateOpen(true);
+            }}>创建每月循环抽奖</Button>
           </Space>
         }
       />
+      <Surface>
+        <Divider orientation="left">每月循环抽奖模板</Divider>
+        <Table
+          rowKey="template_id"
+          size="small"
+          pagination={false}
+          dataSource={templates.filter((item) => !chatId || item.chat_id === chatId)}
+          columns={[
+            { title: '标题', dataIndex: 'title' },
+            { title: '群组', dataIndex: 'chat_id', render: (value: string) => chats.find((chat) => chat.chat_id === value)?.title || value },
+            { title: '周期', render: (_, item: GiveawayTemplate) => `每月 ${item.day_of_month} 日 ${item.start_time} 报名,${item.draw_day_offset ? '次日' : '当天'} ${item.draw_time} 开奖` },
+            { title: '状态', render: (_, item: GiveawayTemplate) => <Space><Tag color={item.active ? 'green' : 'default'}>{item.active ? '运行中' : '已暂停'}</Tag>{item.last_error ? <Typography.Text type="danger">{item.last_error}</Typography.Text> : null}</Space> },
+            { title: '操作', render: (_, item: GiveawayTemplate) => <Space>
+              <Button size="small" onClick={() => {
+                setEditingTemplate(item);
+                templateForm.setFieldsValue({
+                  ...item,
+                  eligibility_chat_ids: item.eligibility_targets.map((target) => target.chat_id),
+                });
+                setTemplateOpen(true);
+              }}>编辑</Button>
+              <Button size="small" onClick={() => runDanger(
+                `${item.active ? '暂停' : '启用'}循环抽奖?`,
+                async () => {
+                  await apiRequest(`/giveaways/templates/${item.template_id}`, jsonOptions('PATCH', { ...item, active: !item.active, confirm: true }));
+                  await loadTemplates();
+                },
+              )}>{item.active ? '暂停' : '启用'}</Button>
+            </Space> },
+          ]}
+        />
+      </Surface>
       <Surface
         actions={
           <Space wrap>
@@ -424,6 +509,9 @@ export default function GiveawaysPage() {
             minimum_points: 0,
             entry_cost: 0,
             participation_reward: 0,
+            max_tickets_per_user: 10,
+            eligibility_mode: 'all',
+            eligibility_chat_ids: [],
             prizes: [{ name: '一等奖', count: 1, points_reward: 0 }],
           }}
           onFinish={(values) => {
@@ -437,7 +525,7 @@ export default function GiveawaysPage() {
                 await apiRequest(
                   '/giveaways',
                   jsonOptions('POST', {
-                    ...values,
+                    ...targetPayload(values),
                     starts_at: startsAt.toISOString(),
                     ends_at: endsAt.toISOString(),
                     confirm: true,
@@ -512,7 +600,84 @@ export default function GiveawaysPage() {
             <Form.Item name="minimum_points" label="最低积分"><InputNumber min={0} /></Form.Item>
             <Form.Item name="entry_cost" label="报名消耗"><InputNumber min={0} /></Form.Item>
             <Form.Item name="participation_reward" label="参与奖励"><InputNumber min={0} /></Form.Item>
+            <Form.Item name="max_tickets_per_user" label="付费时每人最多奖票"><InputNumber min={1} max={100} /></Form.Item>
           </Space>
+          <Divider orientation="left">报名资格</Divider>
+          <Form.Item name="eligibility_mode" label="多个目标的组合方式">
+            <Select options={[{ value: 'all', label: '全部满足' }, { value: 'any', label: '满足任意一个' }]} />
+          </Form.Item>
+          <Form.Item name="eligibility_chat_ids" label="频道或群 ID" extra="机器人须是目标管理员;留空表示只要求当前群成员。">
+            <Select mode="tags" tokenSeparators={[',', ' ']} placeholder="输入频道或群的数字 ID" />
+          </Form.Item>
+        </Form>
+      </Modal>
+      <Modal
+        title={editingTemplate ? '编辑每月循环抽奖' : '创建每月循环抽奖'}
+        open={templateOpen}
+        width={720}
+        okText="保存模板"
+        onCancel={() => setTemplateOpen(false)}
+        onOk={() => templateForm.submit()}
+        destroyOnHidden
+      >
+        <Form
+          form={templateForm}
+          layout="vertical"
+          initialValues={{
+            chat_id: chatId, day_of_month: 1, start_time: '00:00',
+            draw_day_offset: 0, draw_time: '20:00', minimum_points: 0,
+            entry_cost: 0, participation_reward: 0, max_tickets_per_user: 10,
+            eligibility_mode: 'all', eligibility_chat_ids: [],
+            prizes: [{ name: '一等奖', count: 1, points_reward: 0 }],
+          }}
+          onFinish={async (values) => {
+            try {
+              const path = editingTemplate
+                ? `/giveaways/templates/${editingTemplate.template_id}`
+                : '/giveaways/templates';
+              await apiRequest(path, jsonOptions(editingTemplate ? 'PATCH' : 'POST', {
+                ...targetPayload(values), confirm: true,
+              }));
+              setTemplateOpen(false);
+              templateForm.resetFields();
+              await loadTemplates();
+              message.success('循环抽奖模板已保存');
+            } catch (error) {
+              message.error(error instanceof Error ? error.message : '保存模板失败');
+            }
+          }}
+        >
+          <Form.Item name="chat_id" label="群组" rules={[{ required: true }]}>
+            <Select disabled={Boolean(editingTemplate)} options={chats.map((chat) => ({ value: chat.chat_id, label: chat.title }))} />
+          </Form.Item>
+          <Form.Item name="title" label="标题" rules={[{ required: true }]}><Input maxLength={200} /></Form.Item>
+          <Form.Item name="description" label="说明"><Input.TextArea rows={2} maxLength={4000} /></Form.Item>
+          <Space wrap>
+            <Form.Item name="day_of_month" label="每月日期(无此日取月末)"><InputNumber min={1} max={31} /></Form.Item>
+            <Form.Item name="start_time" label="报名时间" rules={[{ pattern: /^([01]\d|2[0-3]):[0-5]\d$/, message: '使用 HH:mm' }]}><Input placeholder="00:00" /></Form.Item>
+            <Form.Item name="draw_day_offset" label="开奖日期"><Select style={{ width: 120 }} options={[{ value: 0, label: '当天' }, { value: 1, label: '次日' }]} /></Form.Item>
+            <Form.Item name="draw_time" label="开奖时间" rules={[{ pattern: /^([01]\d|2[0-3]):[0-5]\d$/, message: '使用 HH:mm' }]}><Input placeholder="20:00" /></Form.Item>
+          </Space>
+          <Form.List name="prizes">{(fields, { add, remove }) => <>
+            <Divider orientation="left">奖项</Divider>
+            {fields.map(({ key, ...field }) => <Space key={key} align="baseline">
+              <Form.Item {...field} name={[field.name, 'name']} rules={[{ required: true }]}><Input placeholder="奖项名称" /></Form.Item>
+              <Form.Item {...field} name={[field.name, 'count']}><InputNumber min={1} max={100} /></Form.Item>
+              <Form.Item {...field} name={[field.name, 'points_reward']}><InputNumber min={0} /></Form.Item>
+              <Button onClick={() => remove(field.name)}>移除</Button>
+            </Space>)}
+            <Button onClick={() => add({ name: '', count: 1, points_reward: 0 })}>添加奖项</Button>
+          </>}</Form.List>
+          <Space wrap>
+            <Form.Item name="minimum_points" label="最低积分"><InputNumber min={0} /></Form.Item>
+            <Form.Item name="entry_cost" label="每张奖票积分"><InputNumber min={0} /></Form.Item>
+            <Form.Item name="participation_reward" label="参与奖励"><InputNumber min={0} /></Form.Item>
+            <Form.Item name="max_tickets_per_user" label="每人最多奖票"><InputNumber min={1} max={100} /></Form.Item>
+          </Space>
+          <Form.Item name="eligibility_mode" label="资格组合"><Select options={[{ value: 'all', label: '全部满足' }, { value: 'any', label: '满足任意一个' }]} /></Form.Item>
+          <Form.Item name="eligibility_chat_ids" label="资格频道/群 ID" extra="机器人必须是目标管理员。">
+            <Select mode="tags" tokenSeparators={[',', ' ']} />
+          </Form.Item>
         </Form>
       </Modal>
       <Modal
@@ -655,6 +820,10 @@ export default function GiveawaysPage() {
               <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.Item label="奖票上限">{selected.entry_cost ? selected.max_tickets_per_user ?? 10 : 1}</Descriptions.Item>
+              <Descriptions.Item label="资格组合">{selected.eligibility_mode === 'any' ? '任一满足' : '全部满足'}</Descriptions.Item>
+              <Descriptions.Item label="资格目标">{selected.eligibility_targets?.map((target) => target.title || target.chat_id).join('、') || '无'}</Descriptions.Item>
+              {selected.eligibility_error ? <Descriptions.Item label="核验异常">{selected.eligibility_error}</Descriptions.Item> : null}
             </Descriptions>
             <Divider orientation="left">参与者({participants.total})</Divider>
             <Table
@@ -672,6 +841,25 @@ export default function GiveawaysPage() {
               scroll={{ x: 820 }}
               size="small"
             />
+            <Divider orientation="left">奖票购买记录({orders.total})</Divider>
+            <Table
+              rowKey="order_id"
+              size="small"
+              columns={[
+                { title: '用户 ID', dataIndex: 'user_id' },
+                { title: '张数', dataIndex: 'quantity' },
+                { title: '积分', dataIndex: 'amount' },
+                { title: '状态', dataIndex: 'status' },
+                { title: '时间', dataIndex: 'created_at', render: (value: string) => dayjs(value).format('YYYY-MM-DD HH:mm') },
+              ]}
+              dataSource={orders.items}
+              pagination={{
+                current: orders.page, pageSize: orders.page_size, total: orders.total,
+                onChange: async (page) => setOrders(await apiRequest<Paged<TicketOrder>>(
+                  `/giveaways/${selected.giveaway_id}/orders?page=${page}&page_size=20`,
+                )),
+              }}
+            />
             <Divider orientation="left">中奖者({selected.winners?.length || 0})</Divider>
             <Table
               rowKey={(winner) => `${winner.tier}:${winner.user_id}`}

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

@@ -407,6 +407,12 @@ export interface Giveaway {
   prizes: Prize[];
   minimum_points: number;
   entry_cost: number;
+  max_tickets_per_user?: number;
+  eligibility_targets?: Array<{ chat_id: string; title?: string; type?: string }>;
+  eligibility_mode?: 'all' | 'any';
+  template_id?: string;
+  period_key?: string;
+  eligibility_error?: string;
   participation_reward: number;
   starts_at?: string;
   ends_at: string;
@@ -423,6 +429,26 @@ export interface Giveaway {
   }>;
 }
 
+export interface GiveawayTemplate {
+  template_id: string;
+  chat_id: string;
+  title: string;
+  description: string;
+  prizes: Prize[];
+  minimum_points: number;
+  entry_cost: number;
+  participation_reward: number;
+  max_tickets_per_user: number;
+  eligibility_targets: Array<{ chat_id: string; title?: string; type?: string }>;
+  eligibility_mode: 'all' | 'any';
+  day_of_month: number;
+  start_time: string;
+  draw_day_offset: 0 | 1;
+  draw_time: string;
+  active: boolean;
+  last_error?: string;
+}
+
 export interface AuditLog {
   source: string;
   actor_id?: string | null;

+ 349 - 0
tests/test_giveaway_recurring_eligibility.py

@@ -0,0 +1,349 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime, timedelta
+from types import SimpleNamespace
+
+import pytest
+from aiohttp import CookieJar
+from aiohttp.test_utils import TestClient, TestServer
+from pyrogram.enums import ChatMemberStatus
+
+
+def _member(status, *, is_member=True):
+    return SimpleNamespace(status=status, is_member=is_member, privileges=None)
+
+
+async def test_eligibility_all_any_and_unknown(app_modules):
+    eligibility = app_modules.load("wbb.services.giveaway_eligibility")
+    giveaway = {
+        "chat_id": -100,
+        "eligibility_targets": [{"chat_id": -200}, {"chat_id": -300}],
+        "eligibility_mode": "all",
+    }
+    app_modules.app.members[(-200, 999)] = _member(ChatMemberStatus.ADMINISTRATOR)
+    app_modules.app.members[(-300, 999)] = _member(ChatMemberStatus.ADMINISTRATOR)
+    app_modules.app.members[(-200, 10)] = _member(ChatMemberStatus.MEMBER)
+    app_modules.app.members[(-300, 10)] = _member(ChatMemberStatus.LEFT)
+    assert await eligibility.check_eligibility(giveaway, 10) == (False, [-300])
+
+    giveaway["eligibility_mode"] = "any"
+    assert await eligibility.check_eligibility(giveaway, 10) == (True, [])
+    app_modules.app.members[(-200, 10)] = _member(ChatMemberStatus.RESTRICTED, is_member=False)
+    assert await eligibility.check_eligibility(giveaway, 10) == (False, [-200, -300])
+
+    app_modules.app.members[(-300, 999)] = _member(ChatMemberStatus.MEMBER)
+    with pytest.raises(eligibility.EligibilityUnavailable):
+        await eligibility.check_eligibility(giveaway, 10)
+
+
+async def test_paid_tickets_idempotent_cap_weight_and_cancel_refund(app_modules):
+    points = app_modules.load("wbb.utils.dbpoints")
+    database = app_modules.load("wbb.utils.dbgiveaway")
+    service = app_modules.load("wbb.services.giveaways")
+    await points.adjust_points(
+        chat_id=-100, user_id=10, delta=100, source=points.SOURCE_ADMIN,
+        idempotency_key="seed:10", reason="seed",
+    )
+    giveaway = await database.create_giveaway(
+        chat_id=-100, creator_id=1, creator_name="Admin", title="Tickets",
+        description="", prizes=[{"name": "Winner", "count": 1}],
+        ends_at=datetime.now(UTC) + timedelta(hours=1),
+        entry_cost=10, max_tickets_per_user=5,
+    )
+    giveaway_id = giveaway["giveaway_id"]
+    status, _ = await service.join_giveaway(
+        giveaway_id, user_id=10, username=None, first_name="Alice"
+    )
+    assert status == "ok"
+    status, _ = await service.buy_additional_tickets(
+        giveaway_id, user_id=10, quantity=4, request_id="callback-1"
+    )
+    assert status == "ok"
+    replay, _ = await service.buy_additional_tickets(
+        giveaway_id, user_id=10, quantity=4, request_id="callback-1"
+    )
+    capped, _ = await service.buy_additional_tickets(
+        giveaway_id, user_id=10, quantity=1, request_id="callback-2"
+    )
+    assert (replay, capped) == ("duplicate", "ticket_limit")
+    participant = await database.get_participant(giveaway_id, 10)
+    assert participant["paid_ticket_count"] == 5
+    assert (await points.get_point_account(-100, 10))["balance"] == 50
+    assert len(service.pick_winners([participant], giveaway["prizes"])) == 1
+    canceled, _, _ = await service.cancel_and_refund_giveaway(
+        giveaway_id, chat_id=-100, publish=False
+    )
+    assert canceled is True
+    assert (await points.get_point_account(-100, 10))["balance"] == 100
+
+
+async def test_first_paid_purchase_can_buy_five_tickets_atomically(app_modules):
+    points = app_modules.load("wbb.utils.dbpoints")
+    database = app_modules.load("wbb.utils.dbgiveaway")
+    service = app_modules.load("wbb.services.giveaways")
+    await points.adjust_points(
+        chat_id=-100, user_id=10, delta=100, source=points.SOURCE_ADMIN,
+        idempotency_key="seed:first-five", reason="seed",
+    )
+    giveaway = await database.create_giveaway(
+        chat_id=-100, creator_id=1, creator_name="Admin", title="Five",
+        description="", prizes=[{"name": "Winner", "count": 1}],
+        ends_at=datetime.now(UTC) + timedelta(hours=1), entry_cost=10,
+    )
+    giveaway_id = giveaway["giveaway_id"]
+    status, _ = await service.buy_additional_tickets(
+        giveaway_id, user_id=10, quantity=5, request_id="first-purchase"
+    )
+    replay, _ = await service.buy_additional_tickets(
+        giveaway_id, user_id=10, quantity=5, request_id="first-purchase"
+    )
+    assert (status, replay) == ("ok", "duplicate")
+    assert await database.count_participants(giveaway_id) == 1
+    participant = await database.get_participant(giveaway_id, 10)
+    assert participant["paid_ticket_count"] == 5
+    assert participant["pending"] is False
+    assert (await points.get_point_account(-100, 10))["balance"] == 50
+
+
+async def test_ticket_purchase_recovers_after_debit_before_ticket_write(app_modules, monkeypatch):
+    points = app_modules.load("wbb.utils.dbpoints")
+    database = app_modules.load("wbb.utils.dbgiveaway")
+    service = app_modules.load("wbb.services.giveaways")
+    await points.adjust_points(
+        chat_id=-100, user_id=10, delta=100, source=points.SOURCE_ADMIN,
+        idempotency_key="seed:recover-ticket", reason="seed",
+    )
+    giveaway = await database.create_giveaway(
+        chat_id=-100, creator_id=1, creator_name="Admin", title="Recover",
+        description="", prizes=[{"name": "Winner", "count": 1}],
+        ends_at=datetime.now(UTC) + timedelta(hours=1), entry_cost=10,
+    )
+    original_adjust = service.adjust_points
+    interrupted = False
+
+    async def interrupt_after_debit(**kwargs):
+        nonlocal interrupted
+        result = await original_adjust(**kwargs)
+        if kwargs["delta"] < 0 and not interrupted:
+            interrupted = True
+            raise RuntimeError("simulated restart after debit")
+        return result
+
+    monkeypatch.setattr(service, "adjust_points", interrupt_after_debit)
+    with pytest.raises(RuntimeError, match="simulated restart"):
+        await service.buy_additional_tickets(
+            giveaway["giveaway_id"], user_id=10, quantity=5,
+            request_id="recover-ticket",
+        )
+    assert (await points.get_point_account(-100, 10))["balance"] == 50
+    monkeypatch.setattr(service, "adjust_points", original_adjust)
+    await service.reconcile_pending_ticket_orders()
+    participant = await database.get_participant(giveaway["giveaway_id"], 10)
+    assert participant["paid_ticket_count"] == 5
+    assert participant["pending"] is False
+    assert (await points.get_point_account(-100, 10))["balance"] == 50
+
+
+async def test_draw_waits_for_membership_recovery_and_excludes_confirmed_leaver(app_modules):
+    points = app_modules.load("wbb.utils.dbpoints")
+    database = app_modules.load("wbb.utils.dbgiveaway")
+    service = app_modules.load("wbb.services.giveaways")
+    for user_id in (10, 11):
+        await points.adjust_points(
+            chat_id=-100, user_id=user_id, delta=20, source=points.SOURCE_ADMIN,
+            idempotency_key=f"seed:eligibility:{user_id}", reason="seed",
+        )
+    giveaway = await database.create_giveaway(
+        chat_id=-100, creator_id=1, creator_name="Admin", title="Membership",
+        description="", prizes=[{"name": "Winner", "count": 1}],
+        ends_at=datetime.now(UTC) + timedelta(hours=1), entry_cost=5,
+        participation_reward=2,
+        eligibility_targets=[{"chat_id": -200}],
+    )
+    app_modules.app.members[(-200, 999)] = _member(ChatMemberStatus.ADMINISTRATOR)
+    for user_id in (10, 11):
+        status, _ = await service.join_giveaway(
+            giveaway["giveaway_id"], user_id=user_id,
+            username=None, first_name=str(user_id),
+        )
+        assert status == "ok"
+    app_modules.app.members[(-200, 999)] = _member(ChatMemberStatus.MEMBER)
+    with pytest.raises(service.GiveawayServiceError, match="资格核验暂不可用"):
+        await service.finish_and_publish_giveaway(giveaway["giveaway_id"], publish=False)
+    paused = await database.get_giveaway(giveaway["giveaway_id"])
+    assert paused["status"] == database.STATUS_DRAWING
+    assert paused["eligibility_error"]
+    app_modules.app.members[(-200, 999)] = _member(ChatMemberStatus.ADMINISTRATOR)
+    app_modules.app.members[(-200, 11)] = _member(ChatMemberStatus.LEFT)
+    completed, _, current = await service.finish_and_publish_giveaway(
+        giveaway["giveaway_id"], publish=False
+    )
+    assert completed is True
+    assert [winner["user_id"] for winner in current["winners"]] == [10]
+    leaver = await database.get_participant(giveaway["giveaway_id"], 11)
+    assert leaver["eligibility_status"] == "ineligible"
+    assert (await points.get_point_account(-100, 11))["balance"] == 15
+
+
+async def test_paused_preselection_draw_can_be_canceled(app_modules):
+    database = app_modules.load("wbb.utils.dbgiveaway")
+    service = app_modules.load("wbb.services.giveaways")
+    giveaway = await database.create_giveaway(
+        chat_id=-100, creator_id=1, creator_name="Admin", title="Paused",
+        description="", prizes=[{"name": "First", "count": 1}],
+        ends_at=datetime.now(UTC) + timedelta(hours=1),
+    )
+    await database.claim_giveaway_draw(giveaway["giveaway_id"])
+    canceled, _, current = await service.cancel_and_refund_giveaway(
+        giveaway["giveaway_id"], chat_id=-100, publish=False
+    )
+    assert canceled is True
+    assert current["status"] == database.STATUS_CANCELED
+
+
+async def test_month_end_schedule_and_materialization_is_idempotent(app_modules):
+    templates = app_modules.load("wbb.services.giveaway_templates")
+    database = app_modules.load("wbb.utils.dbgiveaway")
+    body = {
+        "chat_id": -100, "title": "Monthly", "description": "",
+        "prizes": [{"name": "First", "count": 1}],
+        "day_of_month": 31, "start_time": "00:00",
+        "draw_day_offset": 0, "draw_time": "20:00",
+    }
+    start, draw = templates.period_schedule(body, 2024, 2)
+    assert start.astimezone(templates.TIMEZONE).day == 29
+    assert draw - start == timedelta(hours=20)
+    start, draw = templates.period_schedule(body, 2025, 2)
+    assert start.astimezone(templates.TIMEZONE).day == 28
+
+    now = datetime.now(UTC)
+    local = now.astimezone(templates.TIMEZONE)
+    body["day_of_month"] = local.day
+    body["start_time"] = "00:00"
+    body["draw_day_offset"] = 1
+    body["draw_time"] = "20:00"
+    template = await templates.create_template(body, "Admin")
+    await templates.materialize_template(template, now=now)
+    await templates.materialize_template(template, now=now)
+    items = [item async for item in database.giveawaysdb.find({"template_id": template["template_id"]})]
+    assert len(items) == 1
+    assert items[0]["period_key"] == local.strftime("%Y-%m")
+    assert len(app_modules.app.sent_messages) == 1
+
+
+async def test_due_period_recovers_draw_and_pins_result_once(app_modules):
+    database = app_modules.load("wbb.utils.dbgiveaway")
+    service = app_modules.load("wbb.services.giveaways")
+    giveaway = await database.create_giveaway(
+        chat_id=-100, creator_id=1, creator_name="Admin", title="Catch-up",
+        description="", prizes=[{"name": "First", "count": 1}],
+        ends_at=datetime.now(UTC) + timedelta(hours=1),
+        template_id="monthly-template", period_key="2026-09",
+    )
+    await database.attach_giveaway_message(giveaway["giveaway_id"], -100, 99)
+    await database.giveawaysdb.update_one(
+        {"giveaway_id": giveaway["giveaway_id"]},
+        {"$set": {"ends_at": datetime.now(UTC) - timedelta(seconds=1)}},
+    )
+    due = await database.list_due_giveaways()
+    assert [item["giveaway_id"] for item in due] == [giveaway["giveaway_id"]]
+    await service.resume_pending_giveaway(due[0])
+    finished = await database.get_giveaway(giveaway["giveaway_id"])
+    assert finished["status"] == database.STATUS_FINISHED
+    assert finished["result_pinned"] is True
+    assert finished["publication_pending"] is False
+    assert app_modules.app.pinned_messages == [(-100, finished["result_message_id"])]
+    assert app_modules.app.deleted_messages == [(-100, 99)]
+    assert await database.list_due_giveaways() == []
+
+
+async def test_external_snapshot_parser_and_observation_default(app_modules):
+    risk = app_modules.load("wbb.services.external_risk_rules")
+    assert risk.parse_entries(
+        "yt_spam_domains", b"# comment\nexample.com\nhttps://bad.example/path\nnot a domain\n"
+    ) == ["bad.example", "example.com"]
+    await risk.ensure_indexes()
+    await risk.snapshotsdb.insert_one({
+        "snapshot_id": "s1", "bot_id": "primary", "source_id": "yt_spam_domains",
+        "kind": "domain", "status": "pending",
+    })
+    await risk.entriesdb.insert_one({"snapshot_id": "s1", "value": "example.com"})
+    await risk.publish_snapshot("s1")
+    deleted: list[bool] = []
+
+    async def delete():
+        deleted.append(True)
+
+    message = SimpleNamespace(
+        id=5, chat=SimpleNamespace(id=-100),
+        text="Visit https://sub.example.com/path", caption=None,
+        entities=None, caption_entities=None,
+        from_user=SimpleNamespace(id=10, is_bot=False), delete=delete,
+    )
+    await risk.enforce_external(message)
+    assert deleted == []
+    assert await risk.eventsdb.count_documents({"chat_id": -100}) == 1
+    await risk.set_policy(-100, "delete")
+    app_modules.app.members[(-100, 999)] = SimpleNamespace(
+        status=ChatMemberStatus.ADMINISTRATOR,
+        privileges=SimpleNamespace(can_delete_messages=True, can_restrict_members=False),
+    )
+    await risk.enforce_external(message)
+    assert deleted == [True]
+
+
+async def test_admin_template_api_validates_target_and_exposes_risk_policy(app_modules):
+    admin_api = app_modules.load("wbb.admin.api")
+    application = admin_api.build_admin_application()
+    await application["admin_api"].initialize()
+    client = TestClient(TestServer(application), cookie_jar=CookieJar(unsafe=True))
+    await client.start_server()
+    try:
+        login = await client.post(
+            "/api/admin/v1/auth/login",
+            json={"username": "admin", "password": "qwe0.123456"},
+        )
+        token = (await login.json())["data"]["csrf_token"]
+        changed = await client.put(
+            "/api/admin/v1/auth/password",
+            headers={"X-CSRF-Token": token},
+            json={"current_password": "qwe0.123456", "new_password": "changed-pass-123"},
+        )
+        token = (await changed.json())["data"]["csrf_token"]
+        headers = {"X-CSRF-Token": token}
+        app_modules.app.members[(-100, 999)] = _member(ChatMemberStatus.OWNER)
+        app_modules.app.members[(-200, 999)] = _member(ChatMemberStatus.MEMBER)
+        app_modules.app.chats[-200] = SimpleNamespace(
+            id=-200, title="News", username="news", type="channel"
+        )
+        body = {
+            "chat_id": -100, "title": "Monthly", "description": "",
+            "prizes": [{"name": "First", "count": 1}],
+            "day_of_month": 31, "start_time": "00:00",
+            "draw_day_offset": 0, "draw_time": "20:00",
+            "eligibility_targets": [{"chat_id": -200}], "confirm": True,
+        }
+        denied = await client.post(
+            "/api/admin/v1/giveaways/templates", headers=headers, json=body
+        )
+        assert denied.status == 400
+        app_modules.app.members[(-200, 999)] = _member(ChatMemberStatus.ADMINISTRATOR)
+        created = await client.post(
+            "/api/admin/v1/giveaways/templates", headers=headers, json=body
+        )
+        assert created.status == 201
+        template = (await created.json())["data"]
+        assert template["eligibility_targets"][0]["title"] == "News"
+        listed = await client.get("/api/admin/v1/giveaways/templates")
+        assert (await listed.json())["data"]["items"][0]["template_id"] == template["template_id"]
+        policy = await client.get("/api/admin/v1/external-risk/chats/-100")
+        assert (await policy.json())["data"]["mode"] == "observe"
+    finally:
+        await client.close()
+
+
+def test_new_admin_routes_keep_bot_role_permissions(app_modules):
+    permissions = app_modules.load("wbb.services.bot_permissions")
+    assert permissions.api_permission("POST", "/api/admin/v1/giveaways/templates") == "giveaways.manage"
+    assert permissions.api_permission("POST", "/api/admin/v1/external-risk/sources/yt_spam_domains/stage") == "automation.manage"
+    assert permissions.api_permission("PATCH", "/api/admin/v1/chats/-100/interaction-settings") == "automation.manage"

+ 46 - 0
tests/test_message_cleanup.py

@@ -2,6 +2,7 @@ from __future__ import annotations
 
 import asyncio
 from types import SimpleNamespace
+from unittest.mock import AsyncMock
 
 import pytest
 
@@ -182,3 +183,48 @@ async def test_callback_only_continues_an_already_ephemeral_menu(
     )
 
     assert scheduled == [callback_response]
+
+
+async def test_group_reply_explicitly_schedules_request_and_response(
+    cleanup, monkeypatch
+):
+    scheduled: list[FakeMessage] = []
+    monkeypatch.setattr(
+        cleanup,
+        "schedule_group_cleanup",
+        lambda *messages, **_kwargs: scheduled.extend(messages),
+    )
+    request = FakeMessage(-1001, 18, text="/start")
+    response = FakeMessage(-1001, 19, text="请私聊我查看详细功能。")
+    request.reply = AsyncMock(return_value=response)
+
+    result = await cleanup.reply_group_interaction(
+        request,
+        "请私聊我查看详细功能。",
+        reply_markup=object(),
+    )
+
+    assert result is response
+    request.reply.assert_awaited_once()
+    assert scheduled == [request, response]
+
+
+async def test_chinese_history_command_tracks_reply_with_configured_ttl(
+    cleanup, monkeypatch
+):
+    scheduled: list[tuple[FakeMessage, int]] = []
+    monkeypatch.setattr(
+        cleanup,
+        "schedule_group_cleanup",
+        lambda *messages, delay_seconds=10: scheduled.extend(
+            (message, delay_seconds) for message in messages
+        ),
+    )
+    client = FakeClient()
+    cleanup.install_group_interaction_tracking(client)
+    command = FakeMessage(-1001, 20, text="流水")
+    assert cleanup.begin_group_message_interaction(command, delay_seconds=12)
+    response = await client.send_message(
+        -1001, "最近积分流水", reply_to_message_id=command.id
+    )
+    assert scheduled == [(command, 12), (response, 12)]

+ 3 - 3
tests/test_points_commands.py

@@ -79,7 +79,7 @@ async def test_checkin_schedules_user_and_bot_messages_for_cleanup(
     monkeypatch.setattr(
         points_module,
         "schedule_group_cleanup",
-        lambda *messages: cleanup.extend(messages),
+        lambda *messages, **_kwargs: cleanup.extend(messages),
     )
 
     response = await points_module.points_checkin_command(None, message)
@@ -110,7 +110,7 @@ async def test_checkin_error_schedules_user_and_bot_messages_for_cleanup(
     monkeypatch.setattr(
         points_module,
         "schedule_group_cleanup",
-        lambda *messages: cleanup.extend(messages),
+        lambda *messages, **_kwargs: cleanup.extend(messages),
     )
 
     response = await points_module.points_checkin_command(None, message)
@@ -136,7 +136,7 @@ async def test_balance_schedules_user_and_bot_messages_for_cleanup(
     monkeypatch.setattr(
         points_module,
         "schedule_group_cleanup",
-        lambda *messages: cleanup.extend(messages),
+        lambda *messages, **_kwargs: cleanup.extend(messages),
     )
 
     response = await points_module.points_balance_command(None, message)

+ 18 - 12
wbb/__main__.py

@@ -56,6 +56,7 @@ from wbb.services.bot_permissions import (
     has_any_permission,
     has_permission,
 )
+from wbb.services.message_cleanup import reply_group_interaction
 from wbb.utils import paginate_modules
 from wbb.utils.constants import MARKDOWN
 from wbb.utils.dbfunctions import clean_restart_stage, get_rules
@@ -70,17 +71,15 @@ HELP_MODULE_KEYS = {
     "admin_panel",
     "antiservice",
     "blacklist",
-    "chatbot",
-    "feds",
+    "business_assistant",
     "filters",
     "flood",
     "giveaway",
     "greetings",
-    "karma",
-    "locks",
-    "notes",
     "points",
     "rules",
+    "teacher_directory",
+    "technician_services",
 }
 
 BOT_COMMANDS = [
@@ -390,8 +389,10 @@ FED_MARKUP = InlineKeyboardMarkup(
 @app.on_message(filters.command("start"))
 async def start(_, message):
     if message.chat.type != ChatType.PRIVATE:
-        return await message.reply(
-            "请私聊我查看详细功能。", reply_markup=keyboard
+        return await reply_group_interaction(
+            message,
+            "请私聊我查看详细功能。",
+            reply_markup=keyboard,
         )
     if len(message.text.split()) > 1:
         user = await app.get_users(message.from_user.id)
@@ -512,17 +513,22 @@ async def help_command(_, message):
                         ],
                     ]
                 )
-                await message.reply(
+                await reply_group_interaction(
+                    message,
                     f"点击下方按钮查看“{HELPABLE[name].__MODULE__}”的帮助。",
                     reply_markup=key,
                 )
             else:
-                await message.reply(
-                    "请私聊我查看详细功能。", reply_markup=keyboard
+                await reply_group_interaction(
+                    message,
+                    "请私聊我查看详细功能。",
+                    reply_markup=keyboard,
                 )
         else:
-            await message.reply(
-                "请私聊我查看详细功能。", reply_markup=keyboard
+            await reply_group_interaction(
+                message,
+                "请私聊我查看详细功能。",
+                reply_markup=keyboard,
             )
     else:
         if len(message.command) >= 2:

+ 193 - 0
wbb/admin/api.py

@@ -67,6 +67,36 @@ from wbb.services.chat_management import (
     update_chat_profile,
 )
 from wbb.services.directory import DirectoryServiceError, verify_local_membership
+from wbb.services.external_risk_rules import (
+    SOURCES as EXTERNAL_RISK_SOURCES,
+)
+from wbb.services.external_risk_rules import (
+    get_policy as get_external_risk_policy,
+)
+from wbb.services.external_risk_rules import (
+    list_active_sources as list_active_external_risk_sources,
+)
+from wbb.services.external_risk_rules import (
+    list_snapshot_entries as list_external_risk_snapshot_entries,
+)
+from wbb.services.external_risk_rules import (
+    list_snapshots as list_external_risk_snapshots,
+)
+from wbb.services.external_risk_rules import (
+    publish_snapshot as publish_external_risk_snapshot,
+)
+from wbb.services.external_risk_rules import (
+    set_policy as set_external_risk_policy,
+)
+from wbb.services.external_risk_rules import (
+    stage_snapshot as stage_external_risk_snapshot,
+)
+from wbb.services.giveaway_eligibility import validate_targets
+from wbb.services.giveaway_templates import (
+    create_template,
+    list_templates,
+    update_template,
+)
 from wbb.services.giveaways import (
     GiveawayServiceError,
     cancel_and_refund_giveaway,
@@ -76,6 +106,10 @@ from wbb.services.giveaways import (
     reroll_giveaway,
     update_and_refresh_giveaway,
 )
+from wbb.services.interaction_settings import (
+    get_interaction_settings,
+    save_interaction_settings,
+)
 from wbb.services.point_settings import apply_point_rules
 from wbb.services.technician_reviews import (
     ensure_technician_review_topic,
@@ -149,6 +183,8 @@ from wbb.utils.dbgiveaway import (
     list_giveaways_page,
     list_participants_page,
     remove_giveaway_ban,
+    templatesdb,
+    ticket_ordersdb,
 )
 from wbb.utils.dbpoints import (
     SOURCE_ADMIN,
@@ -202,6 +238,7 @@ BOT_SCOPED_PREFIXES = (
     f"{API_PREFIX}/giveaways",
     f"{API_PREFIX}/points",
     f"{API_PREFIX}/media",
+    f"{API_PREFIX}/external-risk",
 )
 TELEGRAM_ID_KEYS = {
     "chat_id",
@@ -962,6 +999,11 @@ 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/templates", self.giveaway_templates)
+        router.add_post(f"{API_PREFIX}/giveaways/templates", self.giveaway_template_create)
+        router.add_patch(
+            f"{API_PREFIX}/giveaways/templates/{{template_id}}", self.giveaway_template_update
+        )
         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)
@@ -971,6 +1013,9 @@ class AdminApi:
             f"{API_PREFIX}/giveaways/{{giveaway_id}}/participants",
             self.giveaway_participants,
         )
+        router.add_get(
+            f"{API_PREFIX}/giveaways/{{giveaway_id}}/orders", self.giveaway_orders
+        )
         router.add_delete(
             f"{API_PREFIX}/giveaways/{{giveaway_id}}/participants/{{user_id}}",
             self.giveaway_participant_remove,
@@ -982,6 +1027,14 @@ class AdminApi:
             self.giveaway_ban_remove,
         )
         router.add_get(f"{API_PREFIX}/giveaways/{{giveaway_id}}/export", self.giveaway_export)
+        router.add_get(f"{API_PREFIX}/external-risk/sources", self.external_risk_sources)
+        router.add_post(f"{API_PREFIX}/external-risk/sources/{{source_id}}/stage", self.external_risk_stage)
+        router.add_post(f"{API_PREFIX}/external-risk/snapshots/{{snapshot_id}}/publish", self.external_risk_publish)
+        router.add_get(f"{API_PREFIX}/external-risk/snapshots/{{snapshot_id}}/entries", self.external_risk_entries)
+        router.add_get(f"{API_PREFIX}/external-risk/chats/{{chat_id}}", self.external_risk_chat_policy)
+        router.add_patch(f"{API_PREFIX}/external-risk/chats/{{chat_id}}", self.external_risk_chat_policy_update)
+        router.add_get(f"{API_PREFIX}/chats/{{chat_id}}/interaction-settings", self.chat_interaction_settings)
+        router.add_patch(f"{API_PREFIX}/chats/{{chat_id}}/interaction-settings", self.chat_interaction_settings_update)
 
     async def health(self, _: web.Request) -> web.Response:
         return success({"status": "ok"})
@@ -2839,6 +2892,18 @@ class AdminApi:
             raise ApiProblem("invalid_schedule", "开奖时间必须晚于当前时间。")
         if starts_at >= ends_at:
             raise ApiProblem("invalid_schedule", "报名开始时间必须早于开奖时间。")
+        if str(body.get("eligibility_mode", "all")) not in {"all", "any"}:
+            raise ApiProblem("invalid_eligibility_mode", "资格条件组合方式必须是全部或任一。")
+        ticket_limit = parse_int(body.get("max_tickets_per_user", 10), "max_tickets_per_user", minimum=1)
+        if ticket_limit > 100:
+            raise ApiProblem("invalid_ticket_limit", "每人奖票上限不能超过 100。")
+        targets = body.get("eligibility_targets") or []
+        if not isinstance(targets, list):
+            raise ApiProblem("invalid_targets", "资格目标必须是数组。")
+        try:
+            targets = await validate_targets(targets)
+        except ValueError as exc:
+            raise ApiProblem("invalid_targets", str(exc)) from exc
         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,
@@ -2852,9 +2917,126 @@ class AdminApi:
             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),
+            max_tickets_per_user=ticket_limit,
+            eligibility_targets=targets,
+            eligibility_mode=str(body.get("eligibility_mode", "all")),
         )
         return success(giveaway, status=201)
 
+    async def giveaway_templates(self, request: web.Request) -> web.Response:
+        raw = request.query.get("chat_id")
+        return success({"items": await list_templates(int(raw) if raw else None)})
+
+    async def giveaway_template_create(self, request: web.Request) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        chat_id = parse_int(body.get("chat_id"), "chat_id")
+        await ensure_permission(chat_id, "can_change_info")
+        try:
+            body["eligibility_targets"] = await validate_targets(
+                body.get("eligibility_targets") or []
+            )
+            template = await create_template(body, request["admin"]["username"])
+        except (ValueError, KeyError, TypeError) as exc:
+            raise ApiProblem("invalid_template", str(exc)) from exc
+        set_audit(request, "giveaway.template.create", chat_id=chat_id, target_id=template["template_id"])
+        return success(template, status=201)
+
+    async def giveaway_template_update(self, request: web.Request) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        template_id = request.match_info["template_id"]
+        current = await templatesdb.find_one({"template_id": template_id, "bot_id": wbb.BOT_PROFILE_ID})
+        if not current:
+            raise ApiProblem("template_not_found", "未找到循环抽奖模板。", status=404)
+        await ensure_permission(int(current["chat_id"]), "can_change_info")
+        if int(body.get("chat_id", current["chat_id"])) != int(current["chat_id"]):
+            raise ApiProblem("template_chat_fixed", "循环抽奖模板不能更换群组。")
+        merged = {**current, **body}
+        try:
+            if merged.get("active", True):
+                merged["eligibility_targets"] = await validate_targets(
+                    merged.get("eligibility_targets") or []
+                )
+            updated = await update_template(template_id, merged)
+        except (ValueError, KeyError, TypeError) as exc:
+            raise ApiProblem("invalid_template", str(exc)) from exc
+        set_audit(request, "giveaway.template.update", chat_id=int(current["chat_id"]), target_id=template_id)
+        return success(updated)
+
+    async def external_risk_sources(self, request: web.Request) -> web.Response:
+        snapshots = await list_external_risk_snapshots()
+        return success({
+            "sources": EXTERNAL_RISK_SOURCES, "snapshots": snapshots,
+            "active": await list_active_external_risk_sources(),
+        })
+
+    async def external_risk_stage(self, request: web.Request) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        source_id = request.match_info["source_id"]
+        try:
+            snapshot = await stage_external_risk_snapshot(source_id)
+        except ValueError as exc:
+            raise ApiProblem("external_risk_invalid_source", str(exc)) from exc
+        except (ClientError, TimeoutError) as exc:
+            raise ApiProblem("external_risk_source_unavailable", "规则源暂不可访问,请稍后重试。", status=502) from exc
+        set_audit(request, "external_risk.stage", target_id=source_id)
+        return success(snapshot, status=201)
+
+    async def external_risk_publish(self, request: web.Request) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        try:
+            snapshot = await publish_external_risk_snapshot(request.match_info["snapshot_id"])
+        except ValueError as exc:
+            raise ApiProblem("external_risk_snapshot_invalid", str(exc)) from exc
+        set_audit(request, "external_risk.publish", target_id=snapshot["snapshot_id"])
+        return success(snapshot)
+
+    async def external_risk_entries(self, request: web.Request) -> web.Response:
+        page, page_size = page_params(request)
+        try:
+            items, total = await list_external_risk_snapshot_entries(
+                request.match_info["snapshot_id"], page=page, page_size=page_size,
+                query=request.query.get("query", ""),
+            )
+        except ValueError as exc:
+            raise ApiProblem("external_risk_snapshot_not_found", str(exc), status=404) from exc
+        return success({"items": items, "total": total, "page": page, "page_size": page_size})
+
+    async def external_risk_chat_policy(self, request: web.Request) -> web.Response:
+        return success(await get_external_risk_policy(chat_id_param(request)))
+
+    async def external_risk_chat_policy_update(self, request: web.Request) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        chat_id = chat_id_param(request)
+        await ensure_permission(chat_id, "can_change_info")
+        if body.get("mode") == "delete":
+            await ensure_permission(chat_id, "can_delete_messages")
+        try:
+            result = await set_external_risk_policy(chat_id, str(body.get("mode")))
+        except ValueError as exc:
+            raise ApiProblem("external_risk_policy_invalid", str(exc)) from exc
+        set_audit(request, "external_risk.policy", chat_id=chat_id, summary=result["mode"])
+        return success(result)
+
+    async def chat_interaction_settings(self, request: web.Request) -> web.Response:
+        return success(await get_interaction_settings(chat_id_param(request)))
+
+    async def chat_interaction_settings_update(self, request: web.Request) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        chat_id = chat_id_param(request)
+        await ensure_permission(chat_id, "can_change_info")
+        try:
+            saved = await save_interaction_settings(chat_id, body)
+        except (TypeError, ValueError) as exc:
+            raise ApiProblem("invalid_interaction_settings", str(exc)) from exc
+        set_audit(request, "chat.interaction_settings.update", chat_id=chat_id)
+        return success(saved)
+
     async def giveaway_detail(self, request: web.Request) -> web.Response:
         giveaway = await get_giveaway(request.match_info["giveaway_id"])
         if not giveaway:
@@ -2953,6 +3135,17 @@ class AdminApi:
         )
         return success({"items": items, "total": total, "page": page, "page_size": page_size})
 
+    async def giveaway_orders(self, request: web.Request) -> web.Response:
+        giveaway_id = request.match_info["giveaway_id"]
+        giveaway = await get_giveaway(giveaway_id)
+        if not giveaway:
+            raise ApiProblem("giveaway_not_found", "未找到该抽奖。", status=404)
+        page, page_size = page_params(request)
+        query = {"bot_id": wbb.BOT_PROFILE_ID, "giveaway_id": giveaway_id}
+        total = await ticket_ordersdb.count_documents(query)
+        cursor = ticket_ordersdb.find(query).sort("created_at", -1).skip((page - 1) * page_size).limit(page_size)
+        return success({"items": [item async for item in cursor], "total": total, "page": page, "page_size": page_size})
+
     async def giveaway_participant_remove(self, request: web.Request) -> web.Response:
         body = await json_body(request)
         require_confirmation(body)

+ 29 - 13
wbb/modules/admin.py

@@ -41,10 +41,12 @@ from wbb import BOT_ID, BOT_PERMISSIONS, SUDOERS, app, log
 from wbb.core.decorators.errors import capture_err
 from wbb.core.keyboard import ikb
 from wbb.services.bot_permissions import has_permission
+from wbb.services.interaction_settings import get_interaction_settings
 from wbb.services.member_shortcuts import (
     member_action_shortcut_command,
 )
 from wbb.services.message_cleanup import schedule_group_cleanup
+from wbb.utils.dbadmin import record_audit
 from wbb.utils.dbfunctions import (
     add_warn,
     get_warn,
@@ -655,6 +657,9 @@ async def delete_shortcut(_, message: Message):
     group=-999,
 )
 async def member_action_shortcut(client, message: Message):
+    settings = await get_interaction_settings(message.chat.id)
+    if not settings["commands"]["shortcuts"]:
+        return None
     if not has_permission("chat.members", BOT_PERMISSIONS):
         return await message.reply_text(
             "当前机器人角色未启用成员管理权限。"
@@ -675,7 +680,7 @@ async def member_action_shortcut(client, message: Message):
                 message,
                 replied_message,
                 response,
-                delay_seconds=MEMBER_ACTION_CLEANUP_SECONDS,
+                delay_seconds=settings["cleanup_seconds"],
             )
             return response
 
@@ -690,19 +695,30 @@ async def member_action_shortcut(client, message: Message):
         f"action={message.command[0]}"
     )
     try:
+        result = None
         if message.command[0] == "ban":
-            return await banFunc(client, message)
-        if message.command[0] in {"mute", "tmute"}:
-            return await mute(client, message)
-        if message.command[0] == "warn":
-            return await warn_user(client, message)
-        if message.command[0] == "unban":
-            return await unban_func(client, message)
-        if message.command[0] == "unmute":
-            return await unmute(client, message)
-        if message.command[0] == "del":
-            return await delete_shortcut(client, message)
-        return await kickFunc(client, message)
+            result = await banFunc(client, message)
+        elif message.command[0] in {"mute", "tmute"}:
+            result = await mute(client, message)
+        elif message.command[0] == "warn":
+            result = await warn_user(client, message)
+        elif message.command[0] == "unban":
+            result = await unban_func(client, message)
+        elif message.command[0] == "unmute":
+            result = await unmute(client, message)
+        elif message.command[0] == "del":
+            result = await delete_shortcut(client, message)
+        else:
+            result = await kickFunc(client, message)
+        with suppress(Exception):
+            await record_audit(
+                source="telegram", actor_id=getattr(message.from_user, "id", None),
+                actor_name=getattr(message.from_user, "first_name", ""),
+                action=f"chat.shortcut.{message.command[0]}", chat_id=message.chat.id,
+                target_id=getattr(getattr(replied_message, "from_user", None), "id", None),
+                summary=original_text,
+            )
+        return result
     finally:
         message.text = original_text
         message.command = original_command

+ 3 - 1
wbb/modules/blacklist.py

@@ -33,6 +33,7 @@ from wbb.services.blacklist_enforcement import (
     remove_risk_keyword,
     risk_rule_keywords,
 )
+from wbb.services.external_risk_rules import enforce_external
 from wbb.utils.filter_groups import blacklist_filters_group
 
 __MODULE__ = "黑名单"
@@ -103,4 +104,5 @@ async def del_filter(_, message):
 )
 @capture_err
 async def blacklist_filters_re(_, message):
-    await enforce_risk_message(message)
+    result = await enforce_risk_message(message)
+    await enforce_external(message, already_deleted=bool(result and result.deleted))

+ 108 - 16
wbb/modules/giveaway.py

@@ -16,8 +16,10 @@ from pyrogram.types import (
 
 from wbb import app, log
 from wbb.core.decorators.permissions import adminsOnly
+from wbb.services.giveaway_templates import list_templates, materialize_template
 from wbb.services.giveaways import (
     GiveawayServiceError,
+    buy_additional_tickets,
     cancel_and_refund_giveaway,
     create_and_publish_giveaway,
     finish_and_publish_giveaway,
@@ -25,22 +27,27 @@ from wbb.services.giveaways import (
     giveaway_starts_at,
     join_giveaway,
     parse_giveaway_time,
+    publish_finished,
+    reconcile_pending_ticket_orders,
     refresh_giveaway_message,
     remove_and_optionally_refund_participant,
     render_winners,
     reroll_giveaway,
     resume_pending_giveaway,
 )
+from wbb.services.interaction_settings import command_enabled
 from wbb.services.member_identity import display_name
 from wbb.utils.dbgiveaway import (
     add_giveaway_ban,
     as_utc,
     count_participants,
     get_giveaway,
+    giveawaysdb,
     list_due_giveaways,
     list_giveaways_page,
     list_participants_page,
     list_pending_cancellations,
+    list_pending_publications,
     list_running_giveaways,
     list_unannounced_started_giveaways,
     mark_giveaway_start_announced,
@@ -60,7 +67,7 @@ __HELP__ = """/giveaway <报名开始时间 ~ 开奖时间> | <奖项> | <标题
 最后的 50 表示该奖项每位中奖者获得 50 积分。
 时间格式:`YYYY-MM-DD HH:mm`,按北京时间计算。
 
-/gjoin <编号> - 报名参加抽奖。
+/gjoin <编号> [张数] - 报名并购买奖票;免费抽奖只能领一张。
 /glist - 查看进行中的抽奖。
 /gparticipants <编号> - 查看参与者。
 /gend <编号> - 立即开奖。
@@ -213,6 +220,7 @@ async def _render_participants_page(
         offset = (page - 1) * PARTICIPANTS_PAGE_SIZE
         lines.extend(
             f"{offset + index}. {_participant_mention(item)}"
+            f" · {int(item.get('paid_ticket_count', item.get('ticket_count', 1)))} 票"
             for index, item in enumerate(participants, 1)
         )
     else:
@@ -303,24 +311,46 @@ def _render_winners_page(
     )
 
 
-async def _join_user(giveaway_id: str, user) -> str:
-    status, _ = await join_giveaway(
-        giveaway_id,
-        user_id=user.id,
-        username=user.username,
-        first_name=user.first_name,
-        display_name=display_name(user.first_name, user.last_name),
-    )
+async def _join_user(
+    giveaway_id: str, user, *, quantity: int = 1, request_id: str = ""
+) -> str:
+    giveaway = await get_giveaway(giveaway_id)
+    if not giveaway:
+        return "未找到该抽奖。"
+    paid = int(giveaway.get("entry_cost", 0)) > 0
+    if paid:
+        status, _ = await buy_additional_tickets(
+            giveaway_id, user_id=user.id, quantity=quantity,
+            request_id=request_id or f"join:{user.id}",
+            username=user.username, first_name=user.first_name,
+            display_name=display_name(user.first_name, user.last_name),
+        )
+    elif quantity != 1:
+        status = "free_single_ticket"
+    else:
+        status, _ = await join_giveaway(
+            giveaway_id,
+            user_id=user.id,
+            username=user.username,
+            first_name=user.first_name,
+            display_name=display_name(user.first_name, user.last_name),
+        )
     return {
-        "ok": "报名成功。",
-        "duplicate": "你已经报名过该抽奖。",
+        "ok": f"已购买 {quantity} 张奖票。" if paid else "报名成功。",
+        "duplicate": "该次购买已经处理。" if paid else "你已经报名过该抽奖。",
         "missing": "未找到该抽奖。",
         "closed": "该抽奖已经关闭。",
         "not_started": "该抽奖尚未开始报名。",
         "ended": "该抽奖已经结束。",
         "banned": "你已被禁止参加本群抽奖。",
         "not_member": "只有当前群成员可以参加该抽奖。",
+        "not_eligible": "尚未满足抽奖要求的频道或群成员资格。",
+        "eligibility_unavailable": "资格核验暂不可用,请稍后重试。",
         "insufficient_points": "你的积分不足,无法报名。",
+        "ticket_limit": "已达到本期每人奖票上限。",
+        "free_single_ticket": "免费抽奖每人只能领取一张奖票。",
+        "invalid_quantity": "奖票数量无效。",
+        "not_joined": "该成员已被移除,不能继续购买奖票。",
     }.get(status, "报名失败,请稍后重试。")
 
 
@@ -328,13 +358,43 @@ async def _sweep_due_giveaways() -> None:
     await asyncio.sleep(5)
     while True:
         try:
+            await reconcile_pending_ticket_orders()
+            for template in await list_templates():
+                try:
+                    await materialize_template(template)
+                except Exception as exc:
+                    log.error(f"循环抽奖模板 {template.get('template_id')} 生成失败:{exc}")
             for giveaway in await list_unannounced_started_giveaways():
-                if await refresh_giveaway_message(giveaway):
-                    await mark_giveaway_start_announced(giveaway["giveaway_id"])
+                try:
+                    if giveaway.get("template_id"):
+                        previous = await giveawaysdb.find_one(
+                            {
+                                "template_id": giveaway["template_id"],
+                                "period_key": {"$lt": giveaway["period_key"]},
+                                "result_pinned": True,
+                            },
+                            sort=[("period_key", -1)],
+                        )
+                        if previous and previous.get("result_message_id"):
+                            await app.unpin_chat_message(
+                                int(giveaway["chat_id"]), int(previous["result_message_id"])
+                            )
+                            await giveawaysdb.update_one(
+                                {"giveaway_id": previous["giveaway_id"]},
+                                {"$set": {"result_pinned": False}},
+                            )
+                    if await refresh_giveaway_message(giveaway):
+                        await mark_giveaway_start_announced(giveaway["giveaway_id"])
+                except Exception as exc:
+                    log.error(f"抽奖 {giveaway.get('giveaway_id')} 开始处理失败:{exc}")
             pending = await list_due_giveaways()
             pending.extend(await list_pending_cancellations())
             for giveaway in pending:
                 await resume_pending_giveaway(giveaway)
+            for giveaway in await list_pending_publications():
+                await publish_finished(
+                    giveaway, list(giveaway.get("winners", []))
+                )
         except Exception as exc:
             log.error(f"抽奖定时任务失败:{exc}")
         await asyncio.sleep(SWEEP_INTERVAL_SECONDS)
@@ -392,8 +452,27 @@ async def create_giveaway_command(_, message: Message):
 
 @app.on_callback_query(filters.regex(r"^giveaway_join:"))
 async def giveaway_join_callback(_, query: CallbackQuery):
+    if not await command_enabled(query.message.chat.id, "giveaway"):
+        return await query.answer("本群已关闭抽奖指令。", show_alert=True)
     giveaway_id = normalize_giveaway_id(query.data.split(":", 1)[1])
-    await query.answer(await _join_user(giveaway_id, query.from_user), show_alert=True)
+    await query.answer(
+        await _join_user(giveaway_id, query.from_user, request_id=str(query.id)),
+        show_alert=True,
+    )
+
+
+@app.on_callback_query(filters.regex(r"^giveaway_buy:[a-f0-9]+:(?:1|5)$"))
+async def giveaway_buy_callback(_, query: CallbackQuery):
+    if not await command_enabled(query.message.chat.id, "giveaway"):
+        return await query.answer("本群已关闭抽奖指令。", show_alert=True)
+    _, giveaway_id, quantity = query.data.split(":")
+    await query.answer(
+        await _join_user(
+            giveaway_id, query.from_user,
+            quantity=int(quantity), request_id=str(query.id),
+        ),
+        show_alert=True,
+    )
 
 
 @app.on_callback_query(filters.regex(r"^giveaway_participants:[a-f0-9]+$"))
@@ -464,15 +543,26 @@ async def giveaway_winners_noop_callback(_, query: CallbackQuery):
 
 @app.on_message(filters.command("gjoin") & ~filters.private)
 async def giveaway_join_command(_, message: Message):
+    if not await command_enabled(message.chat.id, "giveaway"):
+        return None
     if not message.from_user:
         return await message.reply_text("只有普通用户身份可以参加抽奖。")
     if len(message.command) < 2:
-        return await message.reply_text("用法:/gjoin <抽奖编号>")
-    return await message.reply_text(await _join_user(message.command[1], message.from_user))
+        return await message.reply_text("用法:/gjoin <抽奖编号> [奖票张数]")
+    try:
+        quantity = int(message.command[2]) if len(message.command) > 2 else 1
+    except ValueError:
+        return await message.reply_text("奖票张数必须是整数。")
+    return await message.reply_text(await _join_user(
+        message.command[1], message.from_user, quantity=quantity,
+        request_id=f"message:{message.chat.id}:{message.id}",
+    ))
 
 
 @app.on_message(filters.regex(r"^抽奖$") & filters.group)
 async def giveaway_keyword_command(_, message: Message):
+    if not await command_enabled(message.chat.id, "giveaway"):
+        return None
     giveaways = await list_running_giveaways(chat_id=message.chat.id, limit=10)
     now = utc_now()
     giveaways = [
@@ -502,6 +592,8 @@ async def giveaway_keyword_command(_, message: Message):
 
 @app.on_message(filters.regex(r"^(?:开奖|中奖名单)$") & filters.group)
 async def giveaway_winners_keyword_command(_, message: Message):
+    if not await command_enabled(message.chat.id, "giveaway"):
+        return None
     giveaways, _ = await list_giveaways_page(
         chat_id=message.chat.id,
         status="finished",

+ 18 - 3
wbb/modules/interaction_cleanup.py

@@ -6,21 +6,36 @@ from wbb import app
 from wbb.services.message_cleanup import (
     begin_group_callback_interaction,
     begin_group_message_interaction,
+    clear_current_group_interaction,
     install_group_interaction_tracking,
+    is_group_interaction_candidate,
+    is_group_cleanup_scheduled,
 )
 from wbb.services.point_settings import CHECKIN_BUTTON_TEXT
+from wbb.services.interaction_settings import get_interaction_settings
 
 __MODULE__ = "群聊交互清理"
-__HELP__ = "群内机器人指令和按钮交互消息会在 1 分钟后自动清理。"
+__HELP__ = "群内机器人指令和按钮交互消息会在 10 秒后自动清理。"
 
 install_group_interaction_tracking(app)
 
 
 @app.on_message(group=-2000)
 async def begin_message_cleanup_context(_, message: Message):
-    begin_group_message_interaction(message, menu_texts={CHECKIN_BUTTON_TEXT})
+    if not is_group_interaction_candidate(message, menu_texts={CHECKIN_BUTTON_TEXT}):
+        clear_current_group_interaction()
+        return
+    settings = await get_interaction_settings(message.chat.id)
+    begin_group_message_interaction(
+        message, menu_texts={CHECKIN_BUTTON_TEXT},
+        delay_seconds=settings["cleanup_seconds"],
+    )
 
 
 @app.on_callback_query(group=-2000)
 async def begin_callback_cleanup_context(_, query: CallbackQuery):
-    begin_group_callback_interaction(query)
+    if not getattr(query, "message", None) or not is_group_cleanup_scheduled(query.message):
+        clear_current_group_interaction()
+        return
+    settings = await get_interaction_settings(query.message.chat.id)
+    begin_group_callback_interaction(query, delay_seconds=settings["cleanup_seconds"])

+ 13 - 2
wbb/modules/points.py

@@ -10,6 +10,7 @@ from pyrogram.types import Message, User
 
 from wbb import app, log
 from wbb.core.decorators.permissions import adminsOnly
+from wbb.services.interaction_settings import command_enabled, get_interaction_settings
 from wbb.services.member_identity import non_mention_account_name
 from wbb.services.message_cleanup import schedule_group_cleanup
 from wbb.services.point_settings import (
@@ -98,13 +99,15 @@ def _message_content(message: Message) -> str:
 
 async def _reply_checkin(message: Message, text: str) -> Message:
     response = await message.reply_text(text, parse_mode=ParseMode.DISABLED)
-    schedule_group_cleanup(message, response)
+    settings = await get_interaction_settings(message.chat.id)
+    schedule_group_cleanup(message, response, delay_seconds=settings["cleanup_seconds"])
     return response
 
 
 async def _reply_balance(message: Message, text: str) -> Message:
     response = await message.reply_text(text, parse_mode=ParseMode.HTML)
-    schedule_group_cleanup(message, response)
+    settings = await get_interaction_settings(message.chat.id)
+    schedule_group_cleanup(message, response, delay_seconds=settings["cleanup_seconds"])
     return response
 
 
@@ -153,6 +156,8 @@ def _adjustment_parts(message: Message) -> tuple[str | None, str | None, str]:
     filters.group & (filters.command("points") | filters.regex(r"^积分$"))
 )
 async def points_balance_command(_, message: Message):
+    if not await command_enabled(message.chat.id, "points"):
+        return None
     target = await _resolve_target(message)
     if not target:
         return await _reply_balance(message, "未找到该成员。")
@@ -169,6 +174,8 @@ async def points_balance_command(_, message: Message):
     & (filters.command("checkin") | filters.regex(r"^签到$"))
 )
 async def points_checkin_command(_, message: Message):
+    if not await command_enabled(message.chat.id, "checkin"):
+        return None
     if not message.from_user:
         return await _reply_checkin(message, "匿名管理员无法签到。")
     now = utc_now()
@@ -223,6 +230,8 @@ async def points_checkin_command(_, message: Message):
     & (filters.command("points_rank") | filters.regex(r"^积分排行$"))
 )
 async def points_rank_command(_, message: Message):
+    if not await command_enabled(message.chat.id, "points"):
+        return None
     accounts, _ = await list_point_accounts(
         chat_id=message.chat.id, page=1, page_size=10
     )
@@ -244,6 +253,8 @@ async def points_rank_command(_, message: Message):
     & (filters.command("points_history") | filters.regex(r"^流水$"))
 )
 async def points_history_command(_, message: Message):
+    if not await command_enabled(message.chat.id, "history"):
+        return None
     target = await _resolve_target(message)
     if not target:
         return await message.reply_text("未找到该成员。")

+ 4 - 0
wbb/services/bot_permissions.py

@@ -239,6 +239,8 @@ def api_permission(method: str, path: str) -> str | None:
         return "points.manage"
     if path.startswith("/api/admin/v1/giveaways"):
         return "giveaways.manage"
+    if path.startswith("/api/admin/v1/external-risk"):
+        return "automation.manage"
     if "/giveaway-bans" in path:
         return "giveaways.manage"
     if not path.startswith("/api/admin/v1/chats/"):
@@ -262,6 +264,8 @@ def api_permission(method: str, path: str) -> str | None:
         return "chat.rules"
     if path.endswith("/automation"):
         return "automation.manage"
+    if path.endswith("/interaction-settings"):
+        return "automation.manage"
     if "/points/" in path:
         return "points.manage"
     return None

+ 281 - 0
wbb/services/external_risk_rules.py

@@ -0,0 +1,281 @@
+from __future__ import annotations
+
+import hashlib
+import re
+import unicodedata
+from datetime import UTC, datetime
+from secrets import token_hex
+from typing import Any
+from urllib.parse import urlsplit
+
+from aiohttp import ClientSession, ClientTimeout
+from pymongo import ASCENDING
+
+from wbb import BOT_PROFILE_ID, db, log
+
+SOURCES = {
+    "yt_spam_domains": {
+        "name": "YT-Spam-Lists 域名",
+        "url": "https://raw.githubusercontent.com/ThioJoe/YT-Spam-Lists/main/SpamDomainsList.txt",
+        "repository": "https://github.com/ThioJoe/YT-Spam-Lists",
+        "license": "MIT",
+        "kind": "domain",
+        "max_bytes": 1_000_000,
+    },
+    "phishing_domains": {
+        "name": "Phishing.Database 活跃域名",
+        "url": "https://raw.githubusercontent.com/Phishing-Database/Phishing.Database/master/phishing-domains-ACTIVE.txt",
+        "repository": "https://github.com/Phishing-Database/Phishing.Database",
+        "license": "MIT",
+        "kind": "domain",
+        "max_bytes": 20_000_000,
+    },
+    "zapper_phrases": {
+        "name": "zapper-TGAB 短语候选",
+        "url": "https://raw.githubusercontent.com/RGB-Outl4w/zapper-TGAB/master/spam_phrases.txt",
+        "repository": "https://github.com/RGB-Outl4w/zapper-TGAB",
+        "license": "MIT",
+        "kind": "phrase",
+        "max_bytes": 100_000,
+    },
+}
+
+snapshotsdb = db.external_risk_snapshots
+entriesdb = db.external_risk_entries
+statesdb = db.external_risk_sources
+policiesdb = db.external_risk_chat_policies
+eventsdb = db.external_risk_events
+DOMAIN_RE = re.compile(r"^(?:[a-z0-9-]{1,63}\.)+[a-z0-9-]{2,63}$")
+LINK_RE = re.compile(r"(?:(?:https?://|www\.)[^\s<>]+)", re.IGNORECASE)
+_indexes_ready = False
+
+
+async def ensure_indexes() -> None:
+    global _indexes_ready
+    if _indexes_ready:
+        return
+    await snapshotsdb.create_index([("snapshot_id", ASCENDING)], unique=True)
+    await snapshotsdb.create_index([("bot_id", ASCENDING), ("source_id", ASCENDING), ("created_at", -1)])
+    await entriesdb.create_index([("snapshot_id", ASCENDING), ("value", ASCENDING)], unique=True)
+    await statesdb.create_index([("bot_id", ASCENDING), ("source_id", ASCENDING)], unique=True)
+    await policiesdb.create_index([("bot_id", ASCENDING), ("chat_id", ASCENDING)], unique=True)
+    await eventsdb.create_index([("bot_id", ASCENDING), ("chat_id", ASCENDING), ("created_at", -1)])
+    _indexes_ready = True
+
+
+def normalize_domain(raw: str) -> str | None:
+    value = raw.strip().lower().lstrip("*.").rstrip(".")
+    if value.startswith(("http://", "https://")):
+        value = urlsplit(value).hostname or ""
+    value = value.split("/")[0].split(":")[0]
+    try:
+        value = value.encode("idna").decode("ascii")
+    except UnicodeError:
+        return None
+    if not DOMAIN_RE.fullmatch(value) or ".." in value:
+        return None
+    return value
+
+
+def parse_entries(source_id: str, content: bytes) -> list[str]:
+    source = SOURCES[source_id]
+    values: set[str] = set()
+    for line in content.decode("utf-8-sig", errors="replace").splitlines():
+        line = line.strip()
+        if not line or line.startswith(("#", "//")):
+            continue
+        if source["kind"] == "domain":
+            domain = normalize_domain(line)
+            if domain:
+                values.add(domain)
+        else:
+            phrase = unicodedata.normalize("NFKC", line).casefold().strip()
+            if 5 <= len(phrase) <= 120:
+                values.add(phrase)
+    return sorted(values)
+
+
+async def stage_snapshot(source_id: str) -> dict[str, Any]:
+    await ensure_indexes()
+    if source_id not in SOURCES:
+        raise ValueError("未知外部规则来源。")
+    source = SOURCES[source_id]
+    async with ClientSession(timeout=ClientTimeout(total=60)) as session:
+        async with session.get(source["url"], allow_redirects=False) as response:
+            response.raise_for_status()
+            chunks: list[bytes] = []
+            size = 0
+            async for chunk in response.content.iter_chunked(65536):
+                chunks.append(chunk)
+                size += len(chunk)
+                if size > int(source["max_bytes"]):
+                    raise ValueError("外部规则文件超过大小限制。")
+            content = b"".join(chunks)
+    if len(content) > int(source["max_bytes"]):
+        raise ValueError("外部规则文件超过大小限制。")
+    values = parse_entries(source_id, content)
+    if not values:
+        raise ValueError("外部规则文件没有可导入的条目。")
+    digest = hashlib.sha256(content).hexdigest()
+    existing = await snapshotsdb.find_one(
+        {"bot_id": BOT_PROFILE_ID, "source_id": source_id, "sha256": digest}
+    )
+    if existing and existing.get("status") in {"pending", "published"}:
+        return existing
+    snapshot_id = token_hex(12)
+    now = datetime.now(UTC)
+    snapshot = {
+        "snapshot_id": snapshot_id, "bot_id": BOT_PROFILE_ID,
+        "source_id": source_id, "source_url": source["url"],
+        "repository": source["repository"], "license": source["license"],
+        "kind": source["kind"], "sha256": digest, "entry_count": len(values),
+        "status": "pending", "created_at": now,
+    }
+    await snapshotsdb.insert_one(snapshot)
+    try:
+        for offset in range(0, len(values), 1000):
+            await entriesdb.insert_many(
+                [{"snapshot_id": snapshot_id, "value": value} for value in values[offset:offset + 1000]]
+            )
+    except Exception:
+        await snapshotsdb.update_one(
+            {"snapshot_id": snapshot_id}, {"$set": {"status": "failed"}}
+        )
+        raise
+    return snapshot
+
+
+async def list_snapshots(source_id: str | None = None) -> list[dict[str, Any]]:
+    await ensure_indexes()
+    query: dict[str, Any] = {"bot_id": BOT_PROFILE_ID}
+    if source_id:
+        query["source_id"] = source_id
+    return [item async for item in snapshotsdb.find(query).sort("created_at", -1).limit(100)]
+
+
+async def list_active_sources() -> list[dict[str, Any]]:
+    await ensure_indexes()
+    return [item async for item in statesdb.find({"bot_id": BOT_PROFILE_ID})]
+
+
+async def list_snapshot_entries(
+    snapshot_id: str, *, page: int = 1, page_size: int = 50, query: str = ""
+) -> tuple[list[dict[str, Any]], int]:
+    await ensure_indexes()
+    snapshot = await snapshotsdb.find_one({"snapshot_id": snapshot_id, "bot_id": BOT_PROFILE_ID})
+    if not snapshot:
+        raise ValueError("规则快照不存在。")
+    filters: dict[str, Any] = {"snapshot_id": snapshot_id}
+    if query.strip():
+        filters["value"] = {"$regex": re.escape(query.strip()[:100]), "$options": "i"}
+    page = max(1, int(page))
+    page_size = max(1, min(100, int(page_size)))
+    total = await entriesdb.count_documents(filters)
+    cursor = entriesdb.find(filters).sort("value", ASCENDING).skip((page - 1) * page_size).limit(page_size)
+    return [item async for item in cursor], total
+
+
+async def publish_snapshot(snapshot_id: str) -> dict[str, Any]:
+    await ensure_indexes()
+    snapshot = await snapshotsdb.find_one({"snapshot_id": snapshot_id, "bot_id": BOT_PROFILE_ID})
+    if not snapshot or snapshot["status"] not in {"pending", "published"}:
+        raise ValueError("快照不存在或不可发布。")
+    await statesdb.update_one(
+        {"bot_id": BOT_PROFILE_ID, "source_id": snapshot["source_id"]},
+        {"$set": {"active_snapshot_id": snapshot_id, "updated_at": datetime.now(UTC)}},
+        upsert=True,
+    )
+    await snapshotsdb.update_one(
+        {"snapshot_id": snapshot_id}, {"$set": {"status": "published", "published_at": datetime.now(UTC)}}
+    )
+    return snapshot
+
+
+async def get_policy(chat_id: int) -> dict[str, Any]:
+    await ensure_indexes()
+    stored = await policiesdb.find_one({"bot_id": BOT_PROFILE_ID, "chat_id": int(chat_id)})
+    return {"chat_id": int(chat_id), "mode": (stored or {}).get("mode", "observe")}
+
+
+async def set_policy(chat_id: int, mode: str) -> dict[str, Any]:
+    if mode not in {"off", "observe", "delete"}:
+        raise ValueError("风控模式只能是关闭、观察或删消息。")
+    await ensure_indexes()
+    await policiesdb.update_one(
+        {"bot_id": BOT_PROFILE_ID, "chat_id": int(chat_id)},
+        {"$set": {"mode": mode, "updated_at": datetime.now(UTC)}}, upsert=True,
+    )
+    return {"chat_id": int(chat_id), "mode": mode}
+
+
+def _message_domains(message: Any) -> set[str]:
+    text = str(getattr(message, "text", None) or getattr(message, "caption", None) or "")
+    links = LINK_RE.findall(text)
+    for entity in list(getattr(message, "entities", None) or []) + list(getattr(message, "caption_entities", None) or []):
+        if getattr(entity, "url", None):
+            links.append(str(entity.url))
+    result: set[str] = set()
+    for link in links:
+        domain = normalize_domain(link.rstrip(".,!?;:)]}"))
+        if domain:
+            labels = domain.split(".")
+            result.update(".".join(labels[index:]) for index in range(len(labels) - 1))
+    return result
+
+
+async def detect_external(message: Any) -> list[dict[str, Any]]:
+    await ensure_indexes()
+    active = [item async for item in statesdb.find({"bot_id": BOT_PROFILE_ID, "active_snapshot_id": {"$exists": True}})]
+    if not active:
+        return []
+    domains = _message_domains(message)
+    text = unicodedata.normalize(
+        "NFKC", str(getattr(message, "text", None) or getattr(message, "caption", None) or "")
+    ).casefold()
+    matches: list[dict[str, Any]] = []
+    for state in active:
+        snapshot = await snapshotsdb.find_one({"snapshot_id": state["active_snapshot_id"]})
+        if not snapshot:
+            continue
+        if snapshot["kind"] == "domain" and domains:
+            entry = await entriesdb.find_one(
+                {"snapshot_id": snapshot["snapshot_id"], "value": {"$in": list(domains)}}
+            )
+            if entry:
+                matches.append({"source_id": snapshot["source_id"], "snapshot_id": snapshot["snapshot_id"], "value": entry["value"]})
+        elif snapshot["kind"] == "phrase" and text:
+            entries = [item async for item in entriesdb.find({"snapshot_id": snapshot["snapshot_id"]})]
+            hit = next((item for item in entries if item["value"] in text), None)
+            if hit:
+                matches.append({"source_id": snapshot["source_id"], "snapshot_id": snapshot["snapshot_id"], "value": hit["value"]})
+    return matches
+
+
+async def enforce_external(message: Any, *, already_deleted: bool = False) -> None:
+    chat_id = int(message.chat.id)
+    policy = await get_policy(chat_id)
+    if policy["mode"] == "off" or getattr(getattr(message, "from_user", None), "is_bot", False):
+        return
+    matches = await detect_external(message)
+    if not matches:
+        return
+    deleted = False
+    if policy["mode"] == "delete" and not already_deleted:
+        from wbb.services.blacklist_enforcement import _bot_capabilities, _is_privileged_member
+
+        user = getattr(message, "from_user", None)
+        if user and not await _is_privileged_member(chat_id, int(user.id)):
+            capabilities = await _bot_capabilities(chat_id)
+            if capabilities["delete"] is True:
+                try:
+                    await message.delete()
+                    deleted = True
+                except Exception as exc:
+                    log.error(f"外部风控删除消息失败 chat_id={chat_id}: {exc}")
+    await eventsdb.insert_one({
+        "bot_id": BOT_PROFILE_ID, "chat_id": chat_id,
+        "message_id": int(message.id),
+        "user_id": int(message.from_user.id) if getattr(message, "from_user", None) else None,
+        "matches": matches, "mode": policy["mode"], "deleted": deleted,
+        "created_at": datetime.now(UTC),
+    })

+ 104 - 0
wbb/services/giveaway_eligibility.py

@@ -0,0 +1,104 @@
+from __future__ import annotations
+
+from typing import Any
+
+from pyrogram.enums import ChatMemberStatus
+
+from wbb import BOT_ID, app
+
+
+class EligibilityUnavailable(RuntimeError):
+    """Telegram cannot give a trustworthy membership decision."""
+
+
+def is_member(member: Any) -> bool:
+    if member.status in {ChatMemberStatus.LEFT, ChatMemberStatus.BANNED}:
+        return False
+    if member.status == ChatMemberStatus.RESTRICTED:
+        return bool(getattr(member, "is_member", False))
+    return member.status in {
+        ChatMemberStatus.OWNER,
+        ChatMemberStatus.ADMINISTRATOR,
+        ChatMemberStatus.MEMBER,
+    }
+
+
+async def validate_targets(targets: list[dict[str, Any]]) -> list[dict[str, Any]]:
+    if len(targets) > 10:
+        raise ValueError("最多设置 10 个资格频道或群。")
+    normalized: list[dict[str, Any]] = []
+    seen: set[int] = set()
+    for target in targets:
+        try:
+            chat_id = int(target["chat_id"])
+        except (KeyError, TypeError, ValueError) as exc:
+            raise ValueError("资格目标必须填写有效的 Telegram ID。") from exc
+        if chat_id in seen:
+            continue
+        seen.add(chat_id)
+        try:
+            chat = await app.get_chat(chat_id)
+            bot = await app.get_chat_member(chat_id, BOT_ID)
+        except Exception as exc:
+            raise ValueError(f"机器人无法访问资格目标 {chat_id}。") from exc
+        kind = str(getattr(chat.type, "value", chat.type)).lower()
+        if kind not in {"channel", "group", "supergroup"}:
+            raise ValueError("资格目标只能是频道或群。")
+        if bot.status not in {ChatMemberStatus.OWNER, ChatMemberStatus.ADMINISTRATOR}:
+            raise ValueError(f"机器人必须是资格目标 {chat_id} 的管理员。")
+        normalized.append(
+            {
+                "chat_id": chat_id, "type": kind,
+                "title": str(chat.title or chat_id)[:200],
+                "username": str(getattr(chat, "username", None) or "")[:100],
+            }
+        )
+    return normalized
+
+
+async def check_eligibility(
+    giveaway: dict[str, Any], user_id: int
+) -> tuple[bool, list[int]]:
+    targets = list(giveaway.get("eligibility_targets") or [])
+    chat_ids = [int(giveaway["chat_id"]), *[int(item["chat_id"]) for item in targets]]
+    outcomes: list[bool] = []
+    failed: list[int] = []
+    unknown: list[int] = []
+    for chat_id in dict.fromkeys(chat_ids):
+        try:
+            if chat_id != int(giveaway["chat_id"]):
+                bot = await app.get_chat_member(chat_id, BOT_ID)
+                if bot.status not in {ChatMemberStatus.OWNER, ChatMemberStatus.ADMINISTRATOR}:
+                    raise EligibilityUnavailable(f"机器人不再是 {chat_id} 的管理员")
+            member = await app.get_chat_member(chat_id, user_id)
+        except EligibilityUnavailable:
+            unknown.append(chat_id)
+            continue
+        except Exception:
+            unknown.append(chat_id)
+            continue
+        present = is_member(member)
+        if chat_id == int(giveaway["chat_id"]):
+            if not present:
+                failed.append(chat_id)
+        else:
+            outcomes.append(present)
+            if not present:
+                failed.append(chat_id)
+    mode = giveaway.get("eligibility_mode", "all")
+    if int(giveaway["chat_id"]) in failed:
+        return False, failed
+    if int(giveaway["chat_id"]) in unknown:
+        raise EligibilityUnavailable("无法核验报名群的成员状态")
+    if any(int(item["chat_id"]) == int(giveaway["chat_id"]) for item in targets):
+        outcomes.append(True)
+    if mode == "all" and failed:
+        return False, failed
+    if mode == "any" and any(outcomes):
+        return True, []
+    if unknown:
+        raise EligibilityUnavailable(
+            "无法核验以下频道或群的成员状态:" + ", ".join(map(str, unknown))
+        )
+    eligible = not targets or (all(outcomes) if mode == "all" else any(outcomes))
+    return eligible, ([] if eligible else failed)

+ 179 - 0
wbb/services/giveaway_templates.py

@@ -0,0 +1,179 @@
+from __future__ import annotations
+
+from calendar import monthrange
+from datetime import datetime, timedelta
+from secrets import token_hex
+from typing import Any
+from zoneinfo import ZoneInfo
+
+from wbb import BOT_PROFILE_ID, app
+from wbb.services.giveaway_eligibility import validate_targets
+from wbb.utils.dbgiveaway import (
+    as_utc,
+    attach_giveaway_message,
+    create_giveaway,
+    ensure_giveaway_indexes,
+    giveawaysdb,
+    normalize_prizes,
+    templatesdb,
+    utc_now,
+)
+
+TIMEZONE = ZoneInfo("Asia/Shanghai")
+
+
+def _time(value: str) -> tuple[int, int]:
+    try:
+        parsed = datetime.strptime(value, "%H:%M")
+    except (TypeError, ValueError) as exc:
+        raise ValueError("时间格式必须为 HH:mm。") from exc
+    return parsed.hour, parsed.minute
+
+
+def period_schedule(template: dict[str, Any], year: int, month: int) -> tuple[datetime, datetime]:
+    day = min(int(template["day_of_month"]), monthrange(year, month)[1])
+    start_hour, start_minute = _time(template["start_time"])
+    draw_hour, draw_minute = _time(template["draw_time"])
+    local_start = datetime(year, month, day, start_hour, start_minute, tzinfo=TIMEZONE)
+    local_draw = datetime(year, month, day, draw_hour, draw_minute, tzinfo=TIMEZONE)
+    local_draw += timedelta(days=int(template["draw_day_offset"]))
+    if local_draw <= local_start:
+        raise ValueError("开奖时间必须晚于报名开始时间。")
+    return as_utc(local_start), as_utc(local_draw)
+
+
+def normalize_template(body: dict[str, Any]) -> dict[str, Any]:
+    day = int(body.get("day_of_month", 1))
+    offset = int(body.get("draw_day_offset", 0))
+    if not 1 <= day <= 31 or offset not in {0, 1}:
+        raise ValueError("每月日期必须为 1 至 31,开奖只能在当天或次日。")
+    mode = str(body.get("eligibility_mode", "all"))
+    if mode not in {"all", "any"}:
+        raise ValueError("资格条件组合方式必须是全部或任一。")
+    cost = int(body.get("entry_cost", 0))
+    limit = int(body.get("max_tickets_per_user", 10))
+    if cost < 0 or not 1 <= limit <= 100:
+        raise ValueError("积分和奖票上限无效。")
+    values = {
+        "chat_id": int(body["chat_id"]),
+        "title": str(body.get("title") or "").strip()[:200],
+        "description": str(body.get("description") or "").strip()[:4000],
+        "prizes": normalize_prizes(body["prizes"]),
+        "minimum_points": int(body.get("minimum_points", 0)),
+        "entry_cost": cost,
+        "participation_reward": int(body.get("participation_reward", 0)),
+        "max_tickets_per_user": limit if cost else 1,
+        "eligibility_targets": list(body.get("eligibility_targets") or []),
+        "eligibility_mode": mode,
+        "day_of_month": day,
+        "start_time": str(body.get("start_time", "00:00")),
+        "draw_day_offset": offset,
+        "draw_time": str(body.get("draw_time", "20:00")),
+    }
+    if not values["title"] or any(values[key] < 0 for key in ("minimum_points", "participation_reward")):
+        raise ValueError("标题和积分配置无效。")
+    period_schedule(values, 2024, 2)
+    return values
+
+
+async def create_template(body: dict[str, Any], creator_name: str) -> dict[str, Any]:
+    await ensure_giveaway_indexes()
+    values = normalize_template(body)
+    now = utc_now()
+    template = {
+        **values, "template_id": token_hex(8), "bot_id": BOT_PROFILE_ID,
+        "creator_name": creator_name, "active": True,
+        "created_at": now, "updated_at": now,
+    }
+    await templatesdb.insert_one(template)
+    return template
+
+
+async def list_templates(chat_id: int | None = None) -> list[dict[str, Any]]:
+    await ensure_giveaway_indexes()
+    query: dict[str, Any] = {"bot_id": BOT_PROFILE_ID}
+    if chat_id is not None:
+        query["chat_id"] = int(chat_id)
+    return [item async for item in templatesdb.find(query).sort("created_at", -1)]
+
+
+async def update_template(template_id: str, body: dict[str, Any]) -> dict[str, Any] | None:
+    await ensure_giveaway_indexes()
+    changes = normalize_template(body)
+    changes["updated_at"] = utc_now()
+    if "active" in body:
+        changes["active"] = bool(body["active"])
+    from pymongo import ReturnDocument
+
+    return await templatesdb.find_one_and_update(
+        {"template_id": template_id, "bot_id": BOT_PROFILE_ID},
+        {"$set": changes}, return_document=ReturnDocument.AFTER,
+    )
+
+
+async def materialize_template(template: dict[str, Any], now: datetime | None = None) -> None:
+    await ensure_giveaway_indexes()
+    if not template.get("active"):
+        return
+    now = now or utc_now()
+    local = now.astimezone(TIMEZONE)
+    periods = {(local.year, local.month)}
+    previous = local.replace(day=1) - timedelta(days=1)
+    periods.add((previous.year, previous.month))
+    from pyrogram.enums import ParseMode
+
+    from wbb.services.giveaways import join_markup, render_giveaway
+
+    for year, month in sorted(periods):
+        starts_at, ends_at = period_schedule(template, year, month)
+        if starts_at > now or ends_at <= now or as_utc(template["created_at"]) > ends_at:
+            continue
+        period_key = f"{year:04d}-{month:02d}"
+        existing = await giveawaysdb.find_one(
+            {"template_id": template["template_id"], "period_key": period_key}
+        )
+        if existing and (existing.get("message_id") or existing["status"] != "running"):
+            continue
+        if not existing:
+            try:
+                await validate_targets(list(template.get("eligibility_targets") or []))
+            except ValueError as exc:
+                await templatesdb.update_one(
+                    {"template_id": template["template_id"]},
+                    {"$set": {"last_error": str(exc), "last_error_at": utc_now()}},
+                )
+                raise
+            await templatesdb.update_one(
+                {"template_id": template["template_id"]},
+                {"$unset": {"last_error": "", "last_error_at": ""}},
+            )
+        giveaway = existing or await create_giveaway(
+            chat_id=int(template["chat_id"]), creator_id=0,
+            creator_name=template["creator_name"], title=template["title"],
+            description=template["description"], prizes=template["prizes"],
+            starts_at=starts_at, ends_at=ends_at,
+            minimum_points=template["minimum_points"], entry_cost=template["entry_cost"],
+            participation_reward=template["participation_reward"],
+            max_tickets_per_user=template["max_tickets_per_user"],
+            eligibility_targets=template["eligibility_targets"],
+            eligibility_mode=template["eligibility_mode"],
+            template_id=template["template_id"], period_key=period_key,
+        )
+        if giveaway.get("message_id"):
+            continue
+        # A send may have succeeded just before a restart; inspect recent history first.
+        try:
+            async for message in app.get_chat_history(int(template["chat_id"]), limit=100):
+                if f"抽奖 #{giveaway['giveaway_id']}" in str(getattr(message, "text", "")):
+                    await attach_giveaway_message(giveaway["giveaway_id"], int(template["chat_id"]), message.id)
+                    break
+            else:
+                sent = await app.send_message(
+                    int(template["chat_id"]), await render_giveaway(giveaway),
+                    parse_mode=ParseMode.HTML, reply_markup=join_markup(giveaway["giveaway_id"], int(giveaway.get("entry_cost", 0))),
+                    disable_web_page_preview=True,
+                )
+                await attach_giveaway_message(giveaway["giveaway_id"], int(template["chat_id"]), sent.id)
+        except Exception:
+            # Keep the instance for a later retry instead of losing its period key.
+            raise

+ 349 - 39
wbb/services/giveaways.py

@@ -9,10 +9,13 @@ from html import escape
 from typing import Any
 from zoneinfo import ZoneInfo
 
-from pyrogram.enums import ChatMemberStatus, ParseMode
+from pymongo.errors import DuplicateKeyError
+from pyrogram.enums import ParseMode
 from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup
 
-from wbb import app, log
+from wbb import BOT_PROFILE_ID, app, log
+from wbb.services.giveaway_eligibility import EligibilityUnavailable, check_eligibility
+from wbb.utils.dbadmin import record_audit
 from wbb.utils.dbgiveaway import (
     STATUS_CANCELED,
     STATUS_CANCELING,
@@ -30,11 +33,15 @@ from wbb.utils.dbgiveaway import (
     finish_giveaway,
     get_giveaway,
     get_participant,
+    giveawaysdb,
+    is_giveaway_banned,
     list_participants,
     mark_participant_refunded,
+    participantsdb,
     record_reroll,
     remove_participant,
     save_pending_winners,
+    ticket_ordersdb,
     update_running_giveaway,
     utc_now,
 )
@@ -132,28 +139,44 @@ async def render_giveaway(giveaway: dict[str, Any]) -> str:
     if minimum:
         text.append(f"<b>最低积分:</b>{minimum}")
     if cost:
-        text.append(f"<b>报名消耗:</b>{cost}")
+        text.append(f"<b>每张奖票:</b>{cost} 积分;每人最多 {int(giveaway.get('max_tickets_per_user', 10))} 张")
+    else:
+        text.append("<b>免费抽奖:</b>每人限 1 张奖票")
+    targets = giveaway.get("eligibility_targets") or []
+    if targets:
+        condition = "全部满足" if giveaway.get("eligibility_mode", "all") == "all" else "满足任意一个"
+        labels = [
+            "@" + str(item["username"]) if item.get("username") else str(item.get("title") or item["chat_id"])
+            for item in targets
+        ]
+        text.append(f"<b>报名资格({condition}):</b>{escape('、'.join(labels))}")
     if reward:
         text.append(f"<b>参与奖励:</b>+{reward}")
     return "\n".join(text)
 
 
-def join_markup(giveaway_id: str) -> InlineKeyboardMarkup:
-    return InlineKeyboardMarkup(
-        [
+def join_markup(giveaway_id: str, entry_cost: int = 0) -> InlineKeyboardMarkup:
+    rows = [
             [
                 InlineKeyboardButton(
-                    "立即报名", callback_data=f"giveaway_join:{giveaway_id}"
+                    "立即报名" if not entry_cost else f"购买 1 张({entry_cost} 积分)",
+                    callback_data=f"giveaway_join:{giveaway_id}"
                 )
             ],
+        ]
+    if entry_cost:
+        rows.append([
+            InlineKeyboardButton("购买 5 张", callback_data=f"giveaway_buy:{giveaway_id}:5"),
+        ])
+    rows.extend([
             [
                 InlineKeyboardButton(
                     "查看参与者",
                     callback_data=f"giveaway_participants:{giveaway_id}",
                 )
             ],
-        ]
-    )
+        ])
+    return InlineKeyboardMarkup(rows)
 
 
 async def refresh_giveaway_message(giveaway: dict[str, Any]) -> bool:
@@ -166,7 +189,7 @@ async def refresh_giveaway_message(giveaway: dict[str, Any]) -> bool:
             int(message_id),
             await render_giveaway(giveaway),
             parse_mode=ParseMode.HTML,
-            reply_markup=join_markup(giveaway["giveaway_id"]),
+            reply_markup=join_markup(giveaway["giveaway_id"], int(giveaway.get("entry_cost", 0))),
         )
     except Exception:
         return False
@@ -247,9 +270,15 @@ def pick_winners(
             for participant in participants
             if int(participant["user_id"]) not in picked_user_ids
         ]
-        picked = _RANDOM.sample(
-            remaining, min(int(prize["count"]), len(remaining))
-        )
+        picked = []
+        for _ in range(min(int(prize["count"]), len(remaining))):
+            total = sum(max(1, int(item.get("paid_ticket_count", item.get("ticket_count", 1)))) for item in remaining)
+            position = _RANDOM.randrange(total)
+            for index, item in enumerate(remaining):
+                position -= max(1, int(item.get("paid_ticket_count", item.get("ticket_count", 1))))
+                if position < 0:
+                    picked.append(remaining.pop(index))
+                    break
         for participant in picked:
             user_id = int(participant["user_id"])
             picked_user_ids.add(user_id)
@@ -301,7 +330,7 @@ async def create_and_publish_giveaway(**values: Any) -> dict[str, Any]:
             giveaway["chat_id"],
             await render_giveaway(giveaway),
             parse_mode=ParseMode.HTML,
-            reply_markup=join_markup(giveaway["giveaway_id"]),
+            reply_markup=join_markup(giveaway["giveaway_id"], int(giveaway.get("entry_cost", 0))),
             disable_web_page_preview=True,
         )
     except Exception:
@@ -313,14 +342,6 @@ async def create_and_publish_giveaway(**values: Any) -> dict[str, Any]:
     return giveaway
 
 
-async def _is_current_member(chat_id: int, user_id: int) -> bool:
-    try:
-        member = await app.get_chat_member(chat_id, user_id)
-    except Exception:
-        return False
-    return member.status not in {ChatMemberStatus.LEFT, ChatMemberStatus.BANNED}
-
-
 async def join_giveaway(
     giveaway_id: str,
     *,
@@ -344,10 +365,16 @@ async def join_giveaway(
         existing = await get_participant(giveaway_id, user_id)
         if existing:
             return "duplicate", giveaway
-        if verify_membership and not await _is_current_member(
-            int(giveaway["chat_id"]), user_id
-        ):
-            return "not_member", giveaway
+        if verify_membership:
+            try:
+                eligible, failed = await check_eligibility(giveaway, user_id)
+            except EligibilityUnavailable:
+                return "eligibility_unavailable", giveaway
+            if not eligible:
+                return (
+                    "not_member" if int(giveaway["chat_id"]) in failed else "not_eligible",
+                    giveaway,
+                )
 
         minimum = int(giveaway.get("minimum_points", 0))
         entry_cost = int(giveaway.get("entry_cost", 0))
@@ -417,6 +444,187 @@ async def join_giveaway(
     return result, giveaway
 
 
+async def buy_additional_tickets(
+    giveaway_id: str,
+    *,
+    user_id: int,
+    quantity: int,
+    request_id: str,
+    username: str | None = None,
+    first_name: str | None = None,
+    display_name: str | None = None,
+) -> tuple[str, dict[str, Any] | None]:
+    """Reserve capacity before charging; replaying a request never charges twice."""
+    if quantity < 1 or quantity > 100 or not request_id or len(request_id) > 100:
+        return "invalid_quantity", None
+    order_id = f"{giveaway_id}:{user_id}:{request_id}"
+    async with _giveaway_locks[giveaway_id]:
+        giveaway = await get_giveaway(giveaway_id)
+        if not giveaway:
+            return "missing", None
+        if int(giveaway.get("entry_cost", 0)) <= 0:
+            return "free_single_ticket", giveaway
+        order = await ticket_ordersdb.find_one({"order_id": order_id})
+        if order and order.get("status") == "complete":
+            return "duplicate", giveaway
+        if order and order.get("status") == "rejected":
+            return str(order.get("failure_code") or "ticket_limit"), giveaway
+        if not order:
+            now = utc_now()
+            if giveaway["status"] != STATUS_RUNNING or as_utc(giveaway["ends_at"]) <= now:
+                return "ended", giveaway
+            if giveaway_starts_at(giveaway) > now:
+                return "not_started", giveaway
+            participant = await get_participant(giveaway_id, user_id)
+            if participant and participant.get("active") is False:
+                return "not_joined", giveaway
+            if await is_giveaway_banned(int(giveaway["chat_id"]), user_id):
+                return "banned", giveaway
+            try:
+                eligible, _ = await check_eligibility(giveaway, user_id)
+            except EligibilityUnavailable:
+                return "eligibility_unavailable", giveaway
+            if not eligible:
+                return "not_eligible", giveaway
+            if not participant:
+                account = await get_point_account(int(giveaway["chat_id"]), user_id)
+                if int(account.get("balance", 0)) < int(giveaway.get("minimum_points", 0)):
+                    return "insufficient_points", giveaway
+                try:
+                    await participantsdb.insert_one({
+                        "giveaway_id": giveaway_id,
+                        "chat_id": int(giveaway["chat_id"]),
+                        "user_id": int(user_id),
+                        "username": username,
+                        "first_name": first_name,
+                        "display_name": display_name or first_name,
+                        "entry_cost": int(giveaway["entry_cost"]),
+                        "ticket_count": 0,
+                        "paid_ticket_count": 0,
+                        "pending": True,
+                        "active": True,
+                        "joined_at": now,
+                    })
+                except DuplicateKeyError:
+                    pass
+            order = {
+                "order_id": order_id,
+                "bot_id": BOT_PROFILE_ID,
+                "request_id": request_id,
+                "giveaway_id": giveaway_id,
+                "chat_id": int(giveaway["chat_id"]),
+                "user_id": int(user_id),
+                "quantity": quantity,
+                "amount": int(giveaway["entry_cost"]) * quantity,
+                "username": username,
+                "first_name": first_name,
+                "display_name": display_name,
+                "status": "pending",
+                "created_at": now,
+                "updated_at": now,
+            }
+            try:
+                await ticket_ordersdb.insert_one(order)
+            except Exception:
+                order = await ticket_ordersdb.find_one({"order_id": order_id})
+                if not order:
+                    raise
+        quantity = int(order["quantity"])
+        participant = await get_participant(giveaway_id, user_id)
+        if participant and "ticket_count" not in participant:
+            await participantsdb.update_one(
+                {"giveaway_id": giveaway_id, "user_id": user_id, "ticket_count": {"$exists": False}},
+                {"$set": {"ticket_count": 1, "paid_ticket_count": 1}},
+            )
+        if not participant or participant.get("active") is False or giveaway["status"] in {STATUS_CANCELED, STATUS_CANCELING, STATUS_FINISHED}:
+            debit_key = f"giveaway-ticket:{order_id}"
+            if await get_point_transaction_by_key(debit_key):
+                await adjust_points(
+                    chat_id=int(giveaway["chat_id"]), user_id=user_id,
+                    delta=int(order["amount"]), source=SOURCE_GIVEAWAY_REFUND,
+                    idempotency_key=f"giveaway-ticket-refund:{order_id}",
+                    reference_id=giveaway_id, reason=f"抽奖 #{giveaway_id} 奖票购买取消退款",
+                    username=username, first_name=first_name, display_name=display_name,
+                )
+            if participant and order_id in participant.get("ticket_order_ids", []):
+                await participantsdb.update_one(
+                    {"giveaway_id": giveaway_id, "user_id": user_id, "ticket_order_ids": order_id},
+                    {"$inc": {"ticket_count": -quantity}, "$pull": {"ticket_order_ids": order_id}},
+                )
+            await ticket_ordersdb.update_one(
+                {"order_id": order_id},
+                {"$set": {"status": "rejected", "failure_code": "not_joined", "updated_at": utc_now()}},
+            )
+            return "not_joined", giveaway
+        participant_filter = {
+            "giveaway_id": giveaway_id,
+            "user_id": int(user_id),
+            "active": {"$ne": False},
+        }
+        reserved = await participantsdb.update_one(
+            {
+                **participant_filter,
+                "ticket_order_ids": {"$ne": order_id},
+                "ticket_count": {
+                    "$lte": int(giveaway.get("max_tickets_per_user", 10)) - quantity
+                },
+            },
+            {"$inc": {"ticket_count": quantity}, "$addToSet": {"ticket_order_ids": order_id}},
+        )
+        if not reserved.modified_count:
+            participant = await get_participant(giveaway_id, user_id)
+            if not participant or order_id not in participant.get("ticket_order_ids", []):
+                await ticket_ordersdb.update_one(
+                    {"order_id": order_id}, {"$set": {"status": "rejected", "failure_code": "ticket_limit", "updated_at": utc_now()}}
+                )
+                return "ticket_limit", giveaway
+        try:
+            await adjust_points(
+                chat_id=int(giveaway["chat_id"]), user_id=user_id,
+                delta=-int(order["amount"]), source=SOURCE_GIVEAWAY_ENTRY,
+                idempotency_key=f"giveaway-ticket:{order_id}", reference_id=giveaway_id,
+                reason=f"抽奖 #{giveaway_id} 购买 {quantity} 张奖票",
+                username=username, first_name=first_name, display_name=display_name,
+            )
+        except InsufficientPoints:
+            await participantsdb.update_one(
+                {**participant_filter, "ticket_order_ids": order_id},
+                {"$inc": {"ticket_count": -quantity}, "$pull": {"ticket_order_ids": order_id}},
+            )
+            await ticket_ordersdb.update_one(
+                {"order_id": order_id}, {"$set": {"status": "rejected", "failure_code": "insufficient_points", "updated_at": utc_now()}}
+            )
+            return "insufficient_points", giveaway
+        await participantsdb.update_one(
+            {**participant_filter, "completed_ticket_order_ids": {"$ne": order_id}},
+            {
+                "$inc": {"paid_ticket_count": quantity},
+                "$addToSet": {"completed_ticket_order_ids": order_id},
+                "$set": {"pending": False},
+            },
+        )
+        await ticket_ordersdb.update_one(
+            {"order_id": order_id}, {"$set": {"status": "complete", "updated_at": utc_now()}}
+        )
+        await refresh_giveaway_message(giveaway)
+        return "ok", giveaway
+
+
+async def reconcile_pending_ticket_orders(limit: int = 50) -> None:
+    cursor = ticket_ordersdb.find({"bot_id": BOT_PROFILE_ID, "status": "pending"}).sort("created_at", 1).limit(limit)
+    for order in [item async for item in cursor]:
+        try:
+            await buy_additional_tickets(
+                str(order["giveaway_id"]), user_id=int(order["user_id"]),
+                quantity=int(order["quantity"]),
+                request_id=str(order.get("request_id") or order["order_id"].rsplit(":", 1)[-1]),
+                username=order.get("username"), first_name=order.get("first_name"),
+                display_name=order.get("display_name"),
+            )
+        except Exception as exc:
+            log.error(f"奖票订单 {order['order_id']} 恢复失败:{exc}")
+
+
 async def _entry_attempt_keys(
     giveaway_id: str, user_id: int
 ) -> tuple[str, str, bool]:
@@ -444,8 +652,9 @@ async def _refund_entry(
     display_name: str | None,
     key_suffix: str,
     idempotency_key: str | None = None,
+    amount: int | None = None,
 ) -> bool:
-    entry_cost = int(giveaway.get("entry_cost", 0))
+    entry_cost = int(giveaway.get("entry_cost", 0)) if amount is None else int(amount)
     if entry_cost <= 0:
         return False
     _, created = await adjust_points(
@@ -528,12 +737,67 @@ async def finish_and_publish_giveaway(
                 return False, "该抽奖已经完成开奖。", existing
             return False, "该抽奖当前不在进行中。", existing
 
+        pending_order = await ticket_ordersdb.find_one(
+            {"giveaway_id": giveaway_id, "status": "pending"}
+        )
+        if pending_order:
+            raise GiveawayServiceError(
+                "ticket_order_pending", "奖票订单尚未结算,开奖将稍后重试。", status=503
+            )
         participants = await list_participants(giveaway_id)
+        eligible_participants = [
+            participant for participant in participants
+            if participant.get("eligibility_status") == "eligible"
+        ]
+        if giveaway.get("pending_winners") is not None and not any(
+            "eligibility_status" in item for item in participants
+        ) and not giveaway.get("eligibility_targets"):
+            eligible_participants = participants
         if giveaway.get("pending_winners") is None:
-            pending = pick_winners(participants, giveaway["prizes"])
+            eligible_participants = []
+            try:
+                for participant in participants:
+                    eligible, failed = await check_eligibility(
+                        giveaway, int(participant["user_id"])
+                    )
+                    await participantsdb.update_one(
+                        {"giveaway_id": giveaway_id, "user_id": participant["user_id"]},
+                        {"$set": {
+                            "eligibility_status": "eligible" if eligible else "ineligible",
+                            "eligibility_failed_chat_ids": failed,
+                            "eligibility_checked_at": utc_now(),
+                        }},
+                    )
+                    if eligible:
+                        eligible_participants.append(participant)
+            except EligibilityUnavailable as exc:
+                changed = await giveawaysdb.update_one(
+                    {
+                        "giveaway_id": giveaway_id,
+                        "status": STATUS_DRAWING,
+                        "eligibility_error": {"$ne": str(exc)},
+                    },
+                    {"$set": {"eligibility_error": str(exc), "updated_at": utc_now()}},
+                )
+                if changed.modified_count:
+                    with suppress(Exception):
+                        await record_audit(
+                            source="system", actor_id=None, actor_name="抽奖定时任务",
+                            action="giveaway.eligibility_paused",
+                            chat_id=int(giveaway["chat_id"]), target_id=giveaway_id,
+                            summary="开奖资格核验失败,已暂停并等待重试",
+                            success=False, error=str(exc),
+                        )
+                raise GiveawayServiceError(
+                    "eligibility_unavailable", "资格核验暂不可用,开奖已暂停并将自动重试。", status=503
+                ) from exc
+            await giveawaysdb.update_one(
+                {"giveaway_id": giveaway_id}, {"$unset": {"eligibility_error": ""}}
+            )
+            pending = pick_winners(eligible_participants, giveaway["prizes"])
             giveaway = await save_pending_winners(giveaway_id, pending) or giveaway
         winners = list(giveaway.get("pending_winners", []))
-        await _award_draw_points(giveaway, participants, winners)
+        await _award_draw_points(giveaway, eligible_participants, winners)
         marked = await finish_giveaway(giveaway_id, winners)
         if not marked:
             current = await get_giveaway(giveaway_id)
@@ -552,10 +816,23 @@ async def finish_and_publish_giveaway(
 async def publish_finished(
     giveaway: dict[str, Any], winners: list[dict[str, Any]]
 ) -> None:
+    giveaway = await get_giveaway(giveaway["giveaway_id"]) or giveaway
     chat_id = int(giveaway["chat_id"])
     message_id = giveaway.get("message_id")
     text = render_winners(giveaway, winners)
     result_message = None
+    if giveaway.get("result_message_id"):
+        from types import SimpleNamespace
+
+        result_message = SimpleNamespace(id=int(giveaway["result_message_id"]))
+    else:
+        try:
+            async for recent in app.get_chat_history(chat_id, limit=100):
+                if f"抽奖结果 #{giveaway['giveaway_id']}" in str(getattr(recent, "text", "")):
+                    result_message = recent
+                    break
+        except Exception:
+            pass
     if message_id:
         with suppress(Exception):
             await app.edit_message_text(
@@ -565,20 +842,26 @@ async def publish_finished(
                 parse_mode=ParseMode.HTML,
                 reply_markup=None,
             )
-        with suppress(Exception):
-            result_message = await app.send_message(
-                chat_id,
-                text,
-                parse_mode=ParseMode.HTML,
-                reply_to_message_id=int(message_id),
-            )
+        if result_message is None:
+            with suppress(Exception):
+                result_message = await app.send_message(
+                    chat_id, text, parse_mode=ParseMode.HTML,
+                    reply_to_message_id=int(message_id),
+                )
     if result_message is None:
         with suppress(Exception):
             result_message = await app.send_message(
                 chat_id, text, parse_mode=ParseMode.HTML
             )
-    result_pinned = False
-    if result_message is not None:
+    if result_message is None:
+        log.error(f"抽奖 {giveaway['giveaway_id']} 结果发送失败,等待重试")
+        return
+    await giveawaysdb.update_one(
+        {"giveaway_id": giveaway["giveaway_id"]},
+        {"$set": {"result_message_id": int(result_message.id), "updated_at": utc_now()}},
+    )
+    result_pinned = bool(giveaway.get("result_pinned"))
+    if not result_pinned:
         try:
             await app.pin_chat_message(
                 chat_id,
@@ -597,6 +880,16 @@ async def publish_finished(
             # If Telegram refuses deletion, at least remove the stale pinned entry.
             with suppress(Exception):
                 await app.unpin_chat_message(chat_id, int(message_id))
+    await giveawaysdb.update_one(
+        {"giveaway_id": giveaway["giveaway_id"]},
+        {"$set": {
+            "result_pinned": result_pinned,
+            "publication_pending": not result_pinned,
+            "updated_at": utc_now(),
+        }},
+    )
+    if not result_pinned:
+        return
     for winner in winners:
         with suppress(Exception):
             await app.send_message(
@@ -612,6 +905,7 @@ async def publish_finished(
 async def cancel_and_refund_giveaway(
     giveaway_id: str, *, chat_id: int, publish: bool = True
 ) -> tuple[bool, str, dict[str, Any] | None]:
+    await reconcile_pending_ticket_orders()
     async with _giveaway_locks[giveaway_id]:
         giveaway = await claim_giveaway_cancel(giveaway_id, chat_id)
         if not giveaway:
@@ -630,6 +924,9 @@ async def cancel_and_refund_giveaway(
                 first_name=participant.get("first_name"),
                 display_name=participant.get("display_name"),
                 key_suffix="cancel",
+                amount=int(giveaway.get("entry_cost", 0)) * int(
+                    participant.get("paid_ticket_count", participant.get("ticket_count", 1))
+                ),
             )
         await finalize_giveaway_cancel(giveaway_id)
         giveaway = {**giveaway, "status": STATUS_CANCELED}
@@ -675,6 +972,9 @@ async def remove_and_optionally_refund_participant(
             first_name=participant.get("first_name"),
             display_name=participant.get("display_name"),
             key_suffix="participant-removed",
+            amount=int(giveaway.get("entry_cost", 0)) * int(
+                participant.get("paid_ticket_count", participant.get("ticket_count", 1))
+            ),
         )
     await refresh_giveaway_message(giveaway)
     return True
@@ -717,7 +1017,17 @@ async def reroll_giveaway(
             for participant in await list_participants(giveaway_id)
             if int(participant["user_id"]) not in excluded
         ]
-        winners = pick_winners(participants, prizes)
+        eligible_participants = []
+        try:
+            for participant in participants:
+                eligible, _ = await check_eligibility(giveaway, int(participant["user_id"]))
+                if eligible:
+                    eligible_participants.append(participant)
+        except EligibilityUnavailable as exc:
+            raise GiveawayServiceError(
+                "eligibility_unavailable", "资格核验暂不可用,重抽已暂停。", status=503
+            ) from exc
+        winners = pick_winners(eligible_participants, prizes)
         await record_reroll(
             giveaway_id,
             winners,

+ 39 - 0
wbb/services/interaction_settings.py

@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+from typing import Any
+
+from wbb.utils.dbadmin import get_managed_chat_settings, update_managed_chat_settings
+
+COMMAND_KEYS = {"giveaway", "checkin", "points", "history", "shortcuts"}
+DEFAULT_SETTINGS = {
+    "cleanup_seconds": 10,
+    "commands": {key: True for key in COMMAND_KEYS},
+}
+
+
+def normalize_interaction_settings(value: Any) -> dict[str, Any]:
+    value = value if isinstance(value, dict) else {}
+    seconds = int(value.get("cleanup_seconds", 10))
+    if seconds < 1 or seconds > 300:
+        raise ValueError("消息清理时间必须在 1 到 300 秒之间。")
+    commands = value.get("commands") if isinstance(value.get("commands"), dict) else {}
+    return {
+        "cleanup_seconds": seconds,
+        "commands": {key: bool(commands.get(key, True)) for key in sorted(COMMAND_KEYS)},
+    }
+
+
+async def get_interaction_settings(chat_id: int) -> dict[str, Any]:
+    settings = await get_managed_chat_settings(chat_id)
+    return normalize_interaction_settings(settings.get("interaction_settings"))
+
+
+async def save_interaction_settings(chat_id: int, value: Any) -> dict[str, Any]:
+    settings = normalize_interaction_settings(value)
+    await update_managed_chat_settings(chat_id, {"interaction_settings": settings})
+    return settings
+
+
+async def command_enabled(chat_id: int, key: str) -> bool:
+    settings = await get_interaction_settings(chat_id)
+    return bool(settings["commands"].get(key, True))

+ 46 - 17
wbb/services/message_cleanup.py

@@ -2,16 +2,17 @@ from __future__ import annotations
 
 import asyncio
 import logging
+import re
 from functools import wraps
 from typing import Any
 from weakref import WeakKeyDictionary
 
-GROUP_INTERACTION_TTL_SECONDS = 60
+GROUP_INTERACTION_TTL_SECONDS = 10
 
 _LOGGER = logging.getLogger(__name__)
 _scheduled_messages: set[tuple[int, int]] = set()
 _cleanup_tasks: set[asyncio.Task[Any]] = set()
-_active_interactions: WeakKeyDictionary[asyncio.Task[Any], int] = WeakKeyDictionary()
+_active_interactions: WeakKeyDictionary[asyncio.Task[Any], tuple[int, int]] = WeakKeyDictionary()
 _TRACKED_SEND_METHODS = (
     "send_message",
     "send_photo",
@@ -107,6 +108,17 @@ def schedule_group_cleanup(
         task.add_done_callback(_cleanup_tasks.discard)
 
 
+async def reply_group_interaction(
+    message: Any,
+    text: str,
+    **kwargs: Any,
+) -> Any:
+    """Reply to a group interaction and explicitly clean up both messages."""
+    response = await message.reply(text, **kwargs)
+    schedule_group_cleanup(message, response)
+    return response
+
+
 def is_group_cleanup_scheduled(message: Any) -> bool:
     key = _message_key(message)
     return bool(key and key in _scheduled_messages)
@@ -118,31 +130,47 @@ def clear_current_group_interaction() -> None:
         _active_interactions.pop(task, None)
 
 
-def begin_group_message_interaction(
-    message: Any,
-    *,
-    menu_texts: set[str] | frozenset[str] = frozenset(),
+def is_group_interaction_candidate(
+    message: Any, *, menu_texts: set[str] | frozenset[str] = frozenset()
 ) -> bool:
-    clear_current_group_interaction()
     if not _is_group_message(message):
         return False
     text = str(
         getattr(message, "text", None) or getattr(message, "caption", None) or ""
     ).strip()
     folded = text.casefold()
-    is_command = text.startswith("/") or folded in {"@admin", "@admins"}
-    is_command = is_command or folded.startswith(("@admin ", "@admins "))
-    if not is_command and text not in menu_texts:
+    if text.startswith("/") or folded in {"@admin", "@admins"}:
+        return True
+    if folded.startswith(("@admin ", "@admins ")) or text in menu_texts:
+        return True
+    return bool(
+        re.fullmatch(
+            r"(?:抽奖|开奖|中奖名单|签到|积分|积分排行|流水|封禁|解封|禁言|解除禁言|禁言\d+分钟|警告|删除消息|删除|删|踢出|踢|飞)",
+            text,
+        )
+    )
+
+
+def begin_group_message_interaction(
+    message: Any,
+    *,
+    menu_texts: set[str] | frozenset[str] = frozenset(),
+    delay_seconds: int = GROUP_INTERACTION_TTL_SECONDS,
+) -> bool:
+    clear_current_group_interaction()
+    if not is_group_interaction_candidate(message, menu_texts=menu_texts):
         return False
     task = _current_task()
     if task is None:
         return False
-    _active_interactions[task] = int(message.chat.id)
-    schedule_group_cleanup(message)
+    _active_interactions[task] = (int(message.chat.id), delay_seconds)
+    schedule_group_cleanup(message, delay_seconds=delay_seconds)
     return True
 
 
-def begin_group_callback_interaction(query: Any) -> bool:
+def begin_group_callback_interaction(
+    query: Any, *, delay_seconds: int = GROUP_INTERACTION_TTL_SECONDS
+) -> bool:
     clear_current_group_interaction()
     message = getattr(query, "message", None)
     if (
@@ -154,7 +182,7 @@ def begin_group_callback_interaction(query: Any) -> bool:
     task = _current_task()
     if task is None:
         return False
-    _active_interactions[task] = int(message.chat.id)
+    _active_interactions[task] = (int(message.chat.id), delay_seconds)
     return True
 
 
@@ -168,14 +196,15 @@ def track_current_group_interaction(
     task = _current_task()
     if task is None:
         return
-    chat_id = _active_interactions.get(task)
-    if chat_id is None:
+    context = _active_interactions.get(task)
+    if context is None:
         return
+    chat_id, delay_seconds = context
     messages = result if isinstance(result, (list, tuple)) else (result,)
     for message in messages:
         key = _message_key(message)
         if key and key[0] == chat_id and _is_group_message(message):
-            schedule_group_cleanup(message)
+            schedule_group_cleanup(message, delay_seconds=delay_seconds)
 
 
 def install_group_interaction_tracking(client: Any) -> None:

+ 9 - 0
wbb/utils/dbadmin.py

@@ -363,6 +363,10 @@ async def get_managed_chat_settings(chat_id: int) -> dict[str, Any]:
     )
     if doc is not None:
         doc.setdefault("captcha_enabled", False)
+        doc.setdefault("interaction_settings", {
+            "cleanup_seconds": 10,
+            "commands": {key: True for key in ("giveaway", "checkin", "points", "history", "shortcuts")},
+        })
         return doc
     return {
         "chat_id": int(chat_id),
@@ -373,6 +377,10 @@ async def get_managed_chat_settings(chat_id: int) -> dict[str, Any]:
         "captcha_enabled": False,
         "chatbot_enabled": False,
         "antiflood_enabled": True,
+        "interaction_settings": {
+            "cleanup_seconds": 10,
+            "commands": {key: True for key in ("giveaway", "checkin", "points", "history", "shortcuts")},
+        },
     }
 
 
@@ -390,6 +398,7 @@ async def update_managed_chat_settings(
         "captcha_enabled",
         "chatbot_enabled",
         "antiflood_enabled",
+        "interaction_settings",
     }
     update = {key: value for key, value in values.items() if key in allowed}
     now = utc_now()

+ 60 - 3
wbb/utils/dbgiveaway.py

@@ -22,6 +22,8 @@ ALL_STATUSES = ACTIVE_STATUSES | {STATUS_FINISHED, STATUS_CANCELED}
 giveawaysdb = db.giveaways
 participantsdb = db.giveaway_participants
 giveaway_bansdb = db.giveaway_bans
+ticket_ordersdb = db.giveaway_ticket_orders
+templatesdb = db.giveaway_templates
 
 _index_lock = Lock()
 _indexes_ready = False
@@ -93,6 +95,14 @@ async def ensure_giveaway_indexes() -> None:
         await giveaway_bansdb.create_index(
             [("chat_id", ASCENDING), ("created_at", DESCENDING)]
         )
+        await ticket_ordersdb.create_index([("order_id", ASCENDING)], unique=True)
+        await ticket_ordersdb.create_index([("status", ASCENDING), ("updated_at", ASCENDING)])
+        await templatesdb.create_index([("template_id", ASCENDING)], unique=True)
+        await giveawaysdb.create_index(
+            [("template_id", ASCENDING), ("period_key", ASCENDING)],
+            unique=True,
+            partialFilterExpression={"template_id": {"$exists": True}},
+        )
         _indexes_ready = True
 
 
@@ -109,6 +119,11 @@ async def create_giveaway(
     minimum_points: int = 0,
     entry_cost: int = 0,
     participation_reward: int = 0,
+    max_tickets_per_user: int = 10,
+    eligibility_targets: list[dict[str, Any]] | None = None,
+    eligibility_mode: str = "all",
+    template_id: str | None = None,
+    period_key: str | None = None,
 ) -> dict[str, Any]:
     await ensure_giveaway_indexes()
     title = title.strip()
@@ -122,6 +137,16 @@ async def create_giveaway(
     if normalized_starts_at >= normalized_ends_at:
         raise ValueError("报名开始时间必须早于开奖时间。")
     normalized_prizes = normalize_prizes(prizes)
+    if eligibility_mode not in {"all", "any"}:
+        raise ValueError("资格条件组合方式必须是全部或任一。")
+    cost = _nonnegative_int(entry_cost)
+    ticket_limit = _nonnegative_int(max_tickets_per_user, maximum=100)
+    if ticket_limit < 1:
+        raise ValueError("每人奖票上限至少为 1。")
+    if not cost:
+        ticket_limit = 1
+    if template_id and not period_key:
+        raise ValueError("循环抽奖缺少期次。")
     for _ in range(20):
         giveaway_id = token_hex(3)
         doc = {
@@ -134,7 +159,10 @@ async def create_giveaway(
             "description": description.strip()[:4000],
             "prizes": normalized_prizes,
             "minimum_points": _nonnegative_int(minimum_points),
-            "entry_cost": _nonnegative_int(entry_cost),
+            "entry_cost": cost,
+            "max_tickets_per_user": ticket_limit,
+            "eligibility_targets": list(eligibility_targets or []),
+            "eligibility_mode": eligibility_mode,
             "participation_reward": _nonnegative_int(participation_reward),
             "status": STATUS_RUNNING,
             "created_at": now,
@@ -145,12 +173,21 @@ async def create_giveaway(
             "winners": [],
             "rerolls": [],
         }
-        if normalized_starts_at <= now:
+        if normalized_starts_at <= now and not template_id:
             doc["start_announced_at"] = now
+        if template_id:
+            doc["template_id"] = template_id
+            doc["period_key"] = period_key
         try:
             await giveawaysdb.insert_one(doc)
             return doc
         except DuplicateKeyError:
+            if template_id:
+                existing = await giveawaysdb.find_one(
+                    {"template_id": template_id, "period_key": period_key}
+                )
+                if existing:
+                    return existing
             continue
     raise RuntimeError("无法生成唯一的抽奖编号,请重试。")
 
@@ -348,6 +385,16 @@ async def list_pending_cancellations(limit: int = 20) -> list[dict[str, Any]]:
     return [doc async for doc in cursor]
 
 
+async def list_pending_publications(limit: int = 20) -> list[dict[str, Any]]:
+    await ensure_giveaway_indexes()
+    cursor = giveawaysdb.find({
+        "bot_id": BOT_PROFILE_ID,
+        "status": STATUS_FINISHED,
+        "publication_pending": True,
+    }).sort("finished_at", ASCENDING).limit(limit)
+    return [doc async for doc in cursor]
+
+
 async def claim_giveaway_draw(giveaway_id: str) -> dict[str, Any] | None:
     await ensure_giveaway_indexes()
     normalized_id = normalize_giveaway_id(giveaway_id)
@@ -400,6 +447,7 @@ async def finish_giveaway(
     await ensure_giveaway_indexes()
     values: dict[str, Any] = {
         "status": STATUS_FINISHED,
+        "publication_pending": True,
         "finished_at": utc_now(),
         "updated_at": utc_now(),
     }
@@ -424,7 +472,10 @@ async def claim_giveaway_cancel(
         {
             "giveaway_id": normalized_id,
             "chat_id": int(chat_id),
-            "status": STATUS_RUNNING,
+            "$or": [
+                {"status": STATUS_RUNNING},
+                {"status": STATUS_DRAWING, "pending_winners": {"$exists": False}},
+            ],
         },
         {
             "$set": {
@@ -562,6 +613,7 @@ async def add_participant(
     username: str | None,
     first_name: str | None,
     display_name: str | None = None,
+    ticket_count: int = 1,
 ) -> str:
     await ensure_giveaway_indexes()
     giveaway = await get_giveaway(giveaway_id)
@@ -586,6 +638,8 @@ async def add_participant(
                 "first_name": first_name,
                 "display_name": display_name or first_name,
                 "entry_cost": int(giveaway.get("entry_cost", 0)),
+                "ticket_count": int(ticket_count),
+                "paid_ticket_count": int(ticket_count),
                 "active": True,
                 "joined_at": utc_now(),
             }
@@ -658,6 +712,7 @@ async def count_participants(giveaway_id: str, *, active_only: bool = True) -> i
     }
     if active_only:
         filters["active"] = {"$ne": False}
+        filters["pending"] = {"$ne": True}
     return await participantsdb.count_documents(filters)
 
 
@@ -670,6 +725,7 @@ async def list_participants(
     }
     if active_only:
         filters["active"] = {"$ne": False}
+        filters["pending"] = {"$ne": True}
     cursor = participantsdb.find(filters).sort("joined_at", ASCENDING)
     if limit is not None:
         cursor = cursor.limit(max(1, int(limit)))
@@ -689,6 +745,7 @@ async def list_participants_page(
     }
     if active_only:
         filters["active"] = {"$ne": False}
+        filters["pending"] = {"$ne": True}
     page = max(1, int(page))
     page_size = max(1, min(int(page_size), 100))
     total = await participantsdb.count_documents(filters)