feat: add modal shop and lottery management
This commit is contained in:
@@ -51,6 +51,21 @@ async def ensure_runtime_schema():
|
||||
UNIQUE(chat_id, item_key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_shop_items_chat_enabled ON shop_items(chat_id, enabled);
|
||||
CREATE TABLE IF NOT EXISTS shop_transactions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
chat_id BIGINT NOT NULL DEFAULT 0,
|
||||
user_id BIGINT NOT NULL,
|
||||
username VARCHAR(255) NULL,
|
||||
item_key VARCHAR(50) NOT NULL,
|
||||
item_name VARCHAR(255) NOT NULL,
|
||||
cost INT NOT NULL DEFAULT 0,
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'success',
|
||||
note VARCHAR(500) NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_shop_transactions_chat_created ON shop_transactions(chat_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_shop_transactions_chat_user ON shop_transactions(chat_id, user_id);
|
||||
CREATE TABLE IF NOT EXISTS lottery_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
@@ -78,6 +93,11 @@ async def ensure_runtime_schema():
|
||||
username VARCHAR(255) NULL,
|
||||
first_name VARCHAR(255) NULL
|
||||
);
|
||||
ALTER TABLE lottery_events ADD COLUMN IF NOT EXISTS title VARCHAR(255) NULL;
|
||||
ALTER TABLE lottery_events ADD COLUMN IF NOT EXISTS description TEXT NULL;
|
||||
ALTER TABLE lottery_events ADD COLUMN IF NOT EXISTS draw_type VARCHAR(50) NOT NULL DEFAULT 'people';
|
||||
ALTER TABLE lottery_events ADD COLUMN IF NOT EXISTS draw_at TIMESTAMPTZ NULL;
|
||||
ALTER TABLE lottery_events ADD COLUMN IF NOT EXISTS max_participants INT NOT NULL DEFAULT 0;
|
||||
CREATE INDEX IF NOT EXISTS idx_lottery_events_chat_status ON lottery_events(chat_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_lottery_participants_event_user ON lottery_participants(event_id, user_id);
|
||||
""")
|
||||
|
||||
@@ -155,6 +155,23 @@ class ShopItem(TimestampMixin, Model):
|
||||
indexes = (("chat_id", "enabled"),)
|
||||
|
||||
|
||||
class ShopTransaction(TimestampMixin, Model):
|
||||
"""积分商城交易记录"""
|
||||
id = fields.BigIntField(pk=True, generated=True)
|
||||
chat_id = fields.BigIntField(default=0)
|
||||
user_id = fields.BigIntField()
|
||||
username = fields.CharField(max_length=255, null=True)
|
||||
item_key = fields.CharField(max_length=50)
|
||||
item_name = fields.CharField(max_length=255)
|
||||
cost = fields.IntField(default=0)
|
||||
status = fields.CharField(max_length=30, default="success")
|
||||
note = fields.CharField(max_length=500, null=True)
|
||||
|
||||
class Meta:
|
||||
table = "shop_transactions"
|
||||
indexes = (("chat_id", "created_at"), ("chat_id", "user_id"), ("item_key", "created_at"),)
|
||||
|
||||
|
||||
class LotteryEvent(TimestampMixin, Model):
|
||||
"""群内抽奖活动"""
|
||||
id = fields.BigIntField(pk=True, generated=True)
|
||||
@@ -162,6 +179,11 @@ class LotteryEvent(TimestampMixin, Model):
|
||||
message_id = fields.BigIntField(null=True)
|
||||
creator_id = fields.BigIntField()
|
||||
creator_name = fields.CharField(max_length=255, null=True)
|
||||
title = fields.CharField(max_length=255, null=True)
|
||||
description = fields.TextField(null=True)
|
||||
draw_type = fields.CharField(max_length=50, default="people") # instant/people/time/manual
|
||||
draw_at = fields.DatetimeField(null=True)
|
||||
max_participants = fields.IntField(default=0)
|
||||
prize = fields.CharField(max_length=500)
|
||||
condition_type = fields.CharField(max_length=50, default="all") # all/checkin/min_points
|
||||
condition_value = fields.IntField(default=0)
|
||||
|
||||
+90
-42
@@ -16,8 +16,12 @@ const logs = ref<LogItem[]>([]);
|
||||
const groups = ref<GroupItem[]>([]);
|
||||
const selectedChatId = ref<number | null>(null);
|
||||
const shopItems = ref<ShopItem[]>([]);
|
||||
const shopTransactions = ref<Record<string, any>[]>([]);
|
||||
const lotteries = ref<LotteryItem[]>([]);
|
||||
const lotteryParticipants = ref<Record<string, any>[]>([]);
|
||||
const shopForm = reactive({ item_key: '', name: '', cost: 0, desc: '', enabled: true });
|
||||
const lotteryForm = reactive({ id: 0, title: '', prize: '', description: '', condition_type: 'all', condition_value: 0, draw_type: 'people', end_type: 'people', end_value: 3, max_participants: 3, draw_at: '', status: 'draft' });
|
||||
const activeModal = ref<'shop' | 'lottery' | 'bans' | null>(null);
|
||||
const newMessage = ref('');
|
||||
const sending = ref(false);
|
||||
const restarting = ref(false);
|
||||
@@ -107,6 +111,9 @@ async function loadGroups() {
|
||||
async function loadShopItems() {
|
||||
try { shopItems.value = await fetchJson(`/api/shop-items${selectedQuery.value}`); } catch {}
|
||||
}
|
||||
async function loadShopTransactions() {
|
||||
try { shopTransactions.value = await fetchJson(`/api/shop-transactions${selectedQuery.value}`); } catch {}
|
||||
}
|
||||
async function saveShopItem() {
|
||||
if (!shopForm.item_key || !shopForm.name) return alert('请填写商品编号和名称');
|
||||
await fetchJson('/api/shop-items', {
|
||||
@@ -117,9 +124,33 @@ async function saveShopItem() {
|
||||
await loadShopItems();
|
||||
}
|
||||
async function editShopItem(item: ShopItem) { Object.assign(shopForm, item); }
|
||||
async function deleteShopItem(item: ShopItem) {
|
||||
if (!window.confirm(`确认删除商品 ${item.name}?`)) return;
|
||||
await fetchJson(`/api/shop-items/${item.item_key}${selectedQuery.value}`, { method: 'DELETE' });
|
||||
await loadShopItems();
|
||||
}
|
||||
async function loadLotteries() {
|
||||
try { lotteries.value = await fetchJson(`/api/lotteries${selectedQuery.value}`); } catch {}
|
||||
}
|
||||
function resetLotteryForm() { Object.assign(lotteryForm, { id: 0, title: '', prize: '', description: '', condition_type: 'all', condition_value: 0, draw_type: 'people', end_type: 'people', end_value: 3, max_participants: 3, draw_at: '', status: 'draft' }); }
|
||||
function editLottery(item: LotteryItem) { Object.assign(lotteryForm, { ...item, id: item.id || 0 }); }
|
||||
async function saveLottery() {
|
||||
if (!lotteryForm.title || !lotteryForm.prize) return alert('请填写抽奖标题和奖品');
|
||||
lotteryForm.end_type = lotteryForm.draw_type;
|
||||
if (lotteryForm.draw_type === 'people') lotteryForm.max_participants = Number(lotteryForm.end_value || lotteryForm.max_participants || 1);
|
||||
const url = lotteryForm.id ? `/api/lotteries/${lotteryForm.id}` : '/api/lotteries';
|
||||
await fetchJson(url, { method: lotteryForm.id ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...lotteryForm, chat_id: selectedChatId.value || 0 }) });
|
||||
resetLotteryForm();
|
||||
await loadLotteries();
|
||||
}
|
||||
async function deleteLottery(item: LotteryItem) {
|
||||
if (!window.confirm(`确认删除抽奖 ${item.title || item.prize}?`)) return;
|
||||
await fetchJson(`/api/lotteries/${item.id}`, { method: 'DELETE' });
|
||||
await loadLotteries();
|
||||
}
|
||||
async function loadLotteryParticipants(item: LotteryItem) {
|
||||
lotteryParticipants.value = await fetchJson(`/api/lotteries/${item.id}/participants`);
|
||||
}
|
||||
async function cancelLottery(item: LotteryItem) {
|
||||
if (!window.confirm('确认取消这个抽奖?')) return;
|
||||
await fetchJson(`/api/lotteries/${item.id}/cancel`, { method: 'POST' });
|
||||
@@ -130,7 +161,7 @@ async function drawLottery(item: LotteryItem) {
|
||||
await fetchJson(`/api/lotteries/${item.id}/draw`, { method: 'POST' });
|
||||
await loadLotteries();
|
||||
}
|
||||
function reloadByGroup() { loadChatHistory(); loadBans(); loadStats(); loadShopItems(); loadLotteries(); }
|
||||
function reloadByGroup() { loadChatHistory(); loadBans(); loadStats(); loadShopItems(); loadShopTransactions(); loadLotteries(); }
|
||||
|
||||
function connectSSE() {
|
||||
if (es) es.close();
|
||||
@@ -278,6 +309,9 @@ onBeforeUnmount(() => {
|
||||
<div class="top-actions">
|
||||
<select v-model="selectedChatId" class="form-select" @change="reloadByGroup"><option :value="null">全部群组</option><option v-for="g in groups" :key="g.chat_id" :value="g.chat_id">{{ g.label || g.name }}</option></select>
|
||||
<span class="conn" :class="sseConnected ? 'on' : 'off'">{{ sseConnected ? '实时连接' : '连接中' }}</span>
|
||||
<SButton size="xs" variant="outline" @click="activeModal='shop'; loadShopItems(); loadShopTransactions()">商城</SButton>
|
||||
<SButton size="xs" variant="outline" @click="activeModal='lottery'; loadLotteries()">抽奖</SButton>
|
||||
<SButton size="xs" variant="outline" @click="activeModal='bans'; loadBans()">封禁</SButton>
|
||||
<SButton size="xs" variant="outline" color="primary" :disabled="restartingBot" @click="restartBot">重启Bot</SButton>
|
||||
<SButton size="xs" variant="soft" color="warning" :disabled="restarting" @click="restartService">重启服务</SButton>
|
||||
</div>
|
||||
@@ -340,54 +374,68 @@ onBeforeUnmount(() => {
|
||||
</SCard>
|
||||
</section>
|
||||
|
||||
<section class="manage-grid">
|
||||
<SCard class="panel manage-panel" title="🛒 商品管理" description="按当前群组生效">
|
||||
<div class="shop-form">
|
||||
<SInput v-model="shopForm.item_key" placeholder="编号,如 1" />
|
||||
<SInput v-model="shopForm.name" placeholder="商品名称" />
|
||||
<SInput v-model="shopForm.cost" placeholder="积分价格" />
|
||||
<SInput v-model="shopForm.desc" placeholder="说明" />
|
||||
<label class="check"><input v-model="shopForm.enabled" type="checkbox" /> 启用</label>
|
||||
<SButton size="xs" color="primary" @click="saveShopItem">保存商品</SButton>
|
||||
<div v-if="activeModal" class="modal-mask" @click.self="activeModal=null">
|
||||
<div class="modal-card">
|
||||
<div class="modal-head">
|
||||
<h2>{{ activeModal === 'shop' ? '🛒 商城管理' : activeModal === 'lottery' ? '🎁 抽奖管理' : '🚫 封禁记录' }}</h2>
|
||||
<SButton size="xs" variant="outline" @click="activeModal=null">关闭</SButton>
|
||||
</div>
|
||||
<div class="mini-list">
|
||||
<div v-for="item in shopItems" :key="item.item_key" class="mini-row">
|
||||
<span><b>{{ item.item_key }}</b> {{ item.name }} — {{ item.cost }} 分 <em>{{ item.enabled ? '启用' : '停用' }}</em></span>
|
||||
<SButton size="xs" variant="outline" @click="editShopItem(item)">编辑</SButton>
|
||||
|
||||
<div v-if="activeModal === 'shop'" class="modal-body">
|
||||
<div class="shop-form">
|
||||
<SInput v-model="shopForm.item_key" placeholder="编号,如 1" />
|
||||
<SInput v-model="shopForm.name" placeholder="商品名称" />
|
||||
<SInput v-model="shopForm.cost" placeholder="积分价格" />
|
||||
<SInput v-model="shopForm.desc" placeholder="说明" />
|
||||
<label class="check"><input v-model="shopForm.enabled" type="checkbox" /> 启用</label>
|
||||
<SButton size="xs" color="primary" @click="saveShopItem">保存</SButton>
|
||||
</div>
|
||||
<h3>商品列表</h3>
|
||||
<div class="mini-list">
|
||||
<div v-for="item in shopItems" :key="item.item_key" class="mini-row">
|
||||
<span><b>{{ item.item_key }}</b> {{ item.name }} — {{ item.cost }} 分 <em>{{ item.enabled ? '启用' : '停用' }}</em></span>
|
||||
<div class="row-actions"><SButton size="xs" variant="outline" @click="editShopItem(item)">编辑</SButton><SButton size="xs" variant="soft" color="danger" @click="deleteShopItem(item)">删除</SButton></div>
|
||||
</div>
|
||||
</div>
|
||||
<h3>交易记录</h3>
|
||||
<div class="mini-list"><div v-for="tx in shopTransactions" :key="tx.id" class="mini-row"><span>@{{ tx.username || tx.user_id }} 兑换 {{ tx.item_name }},消耗 {{ tx.cost }} 分</span><em>{{ formatTime(tx.created_at) }}</em></div></div>
|
||||
</div>
|
||||
</SCard>
|
||||
<SCard class="panel manage-panel" title="🎁 抽奖管理" description="查看、取消、手动开奖">
|
||||
<div class="mini-list">
|
||||
<div v-if="!lotteries.length" class="empty">暂无抽奖</div>
|
||||
<div v-for="item in lotteries" :key="item.id" class="mini-row">
|
||||
<span>#{{ item.id }} {{ item.prize }}|{{ item.status }}|{{ formatTime(item.created_at) }}</span>
|
||||
<div class="row-actions">
|
||||
<SButton size="xs" variant="outline" :disabled="item.status !== 'active'" @click="drawLottery(item)">开奖</SButton>
|
||||
<SButton size="xs" variant="soft" color="warning" :disabled="item.status !== 'active'" @click="cancelLottery(item)">取消</SButton>
|
||||
|
||||
<div v-else-if="activeModal === 'lottery'" class="modal-body">
|
||||
<div class="lottery-form">
|
||||
<SInput v-model="lotteryForm.title" placeholder="抽奖标题" />
|
||||
<SInput v-model="lotteryForm.prize" placeholder="奖品" />
|
||||
<SInput v-model="lotteryForm.description" placeholder="描述/开奖条件说明" />
|
||||
<select v-model="lotteryForm.condition_type" class="form-select"><option value="all">所有人</option><option value="checkin">今日签到</option><option value="min_points">积分门槛</option></select>
|
||||
<SInput v-model="lotteryForm.condition_value" placeholder="条件值" />
|
||||
<select v-model="lotteryForm.draw_type" class="form-select"><option value="instant">即开即中</option><option value="people">人满开奖</option><option value="time">指定时间/延时开奖</option><option value="manual">手动开奖</option></select>
|
||||
<SInput v-model="lotteryForm.end_value" placeholder="人数或分钟数" />
|
||||
<SInput v-model="lotteryForm.draw_at" placeholder="指定时间,如 2026-07-07 20:00" />
|
||||
<select v-model="lotteryForm.status" class="form-select"><option value="draft">草稿</option><option value="active">进行中</option><option value="cancelled">取消</option></select>
|
||||
<SButton size="xs" color="primary" @click="saveLottery">保存抽奖</SButton>
|
||||
<SButton size="xs" variant="outline" @click="resetLotteryForm">清空</SButton>
|
||||
</div>
|
||||
<div class="mini-list">
|
||||
<div v-for="item in lotteries" :key="item.id" class="mini-row">
|
||||
<span>#{{ item.id }} {{ item.title || item.prize }}|{{ item.draw_type || item.end_type }}|{{ item.status }}|{{ formatTime(item.created_at) }}</span>
|
||||
<div class="row-actions"><SButton size="xs" variant="outline" @click="editLottery(item); loadLotteryParticipants(item)">编辑/参与</SButton><SButton size="xs" variant="outline" :disabled="item.status !== 'active'" @click="drawLottery(item)">开奖</SButton><SButton size="xs" variant="soft" color="warning" :disabled="item.status !== 'active'" @click="cancelLottery(item)">取消</SButton><SButton size="xs" variant="soft" color="danger" @click="deleteLottery(item)">删除</SButton></div>
|
||||
</div>
|
||||
</div>
|
||||
<h3>参与记录</h3>
|
||||
<div class="mini-list"><div v-for="p in lotteryParticipants" :key="p.id" class="mini-row"><span>@{{ p.username || p.first_name || p.user_id }}</span><em>{{ formatTime(p.created_at) }}</em></div></div>
|
||||
</div>
|
||||
|
||||
<div v-else class="modal-body">
|
||||
<div class="ban-list">
|
||||
<div v-if="!bans.length" class="empty">暂无封禁记录</div>
|
||||
<div v-for="(item, i) in bans" :key="item.id || i" class="ban-item">
|
||||
<div class="ban-main"><b>@{{ item.username || item.first_name || item.user_id }}</b><span>{{ item.reason || '-' }}</span></div>
|
||||
<div class="ban-meta"><span>{{ formatTime(item.time) }}</span><SButton v-if="item.auto_unban" size="xs" variant="outline" color="success" :disabled="item._loading" @click="unban(item)">解封</SButton><STag v-else size="xs" color="secondary" variant="soft">已处理</STag></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SCard>
|
||||
</section>
|
||||
|
||||
<SCard class="ban-panel" title="🚫 封禁记录" :description="`${bans.length} 条`">
|
||||
<template #extra><SButton size="xs" variant="outline" @click="loadBans">刷新</SButton></template>
|
||||
<div class="ban-list">
|
||||
<div v-if="!bans.length" class="empty">暂无封禁记录</div>
|
||||
<div v-for="(item, i) in bans" :key="item.id || i" class="ban-item">
|
||||
<div class="ban-main">
|
||||
<b>@{{ item.username || item.first_name || item.user_id }}</b>
|
||||
<span>{{ item.reason || '-' }}</span>
|
||||
</div>
|
||||
<div class="ban-meta">
|
||||
<span>{{ formatTime(item.time) }}</span>
|
||||
<SButton v-if="item.auto_unban" size="xs" variant="outline" color="success" :disabled="item._loading" @click="unban(item)">解封</SButton>
|
||||
<STag v-else size="xs" color="secondary" variant="soft">已处理</STag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SCard>
|
||||
</div>
|
||||
|
||||
<form class="send-box" @submit.prevent="sendMessage">
|
||||
<SInput v-model="newMessage" placeholder="发送消息到 TG 群" />
|
||||
|
||||
@@ -85,3 +85,16 @@ button { font-family: inherit; }
|
||||
.shop-form { grid-template-columns: 1fr; }
|
||||
.mini-row { align-items: flex-start; flex-direction: column; }
|
||||
}
|
||||
|
||||
.modal-mask { position: fixed; inset: 0; z-index: 50; display: flex; align-items: center; justify-content: center; padding: 18px; background: rgba(15,23,42,.48); backdrop-filter: blur(8px); }
|
||||
.modal-card { width: min(1080px, 100%); max-height: calc(100vh - 36px); overflow: hidden; border-radius: 20px; background: rgba(255,255,255,.98); box-shadow: 0 28px 80px rgba(15,23,42,.32); border: 1px solid rgba(226,232,240,.9); }
|
||||
.modal-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid #e2e8f0; }
|
||||
.modal-head h2 { margin: 0; font-size: 18px; }
|
||||
.modal-body { max-height: calc(100vh - 112px); overflow: auto; padding: 14px 16px 18px; }
|
||||
.modal-body h3 { margin: 14px 0 8px; font-size: 14px; color: #334155; }
|
||||
.lottery-form { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; align-items: center; margin-bottom: 10px; }
|
||||
@media (max-width: 768px) {
|
||||
.modal-mask { align-items: stretch; padding: 8px; }
|
||||
.modal-card { max-height: calc(100vh - 16px); border-radius: 16px; }
|
||||
.lottery-form { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
+11
-6
@@ -151,9 +151,11 @@ async def build_lottery_text(event, count: int | None = None) -> str:
|
||||
return (
|
||||
"🎁 群内抽奖\n"
|
||||
"━━━━━━━━━━━━\n"
|
||||
f"🏷️ 标题:{event.title or event.prize}\n"
|
||||
f"🎯 奖品:{event.prize}\n"
|
||||
f"👤 发起人:@{event.creator_name or event.creator_id}\n"
|
||||
f"✅ 参与条件:{await lottery_condition_text(event)}\n"
|
||||
+ (f"📝 描述:{event.description}\n" if getattr(event, "description", None) else "")
|
||||
+ f"✅ 参与条件:{await lottery_condition_text(event)}\n"
|
||||
f"⏱️ 结束条件:{await lottery_end_text(event)}\n"
|
||||
f"👥 当前参与:{count} 人\n\n"
|
||||
"点击下方按钮参与抽奖。"
|
||||
@@ -177,7 +179,8 @@ async def schedule_lottery_draw(context: ContextTypes.DEFAULT_TYPE, event_id: in
|
||||
text = (
|
||||
"🎉 抽奖已开奖\n"
|
||||
"━━━━━━━━━━━━\n"
|
||||
f"🎯 奖品:{event.prize}\n"
|
||||
f"🏷️ 标题:{event.title or event.prize}\n"
|
||||
f"🎯 奖品:{event.prize}\n"
|
||||
f"👥 参与人数:{result['count']} 人\n"
|
||||
f"🏆 中奖用户:@{winner.username or winner.first_name or winner.user_id}"
|
||||
)
|
||||
@@ -209,7 +212,7 @@ async def create_lottery_from_text(update: Update, context: ContextTypes.DEFAULT
|
||||
await record_bot_message(sent, lottery_text)
|
||||
event.message_id = sent.message_id
|
||||
await event.save()
|
||||
if event.end_type == "minutes":
|
||||
if event.draw_type == "time" or event.end_type in {"minutes", "time"}:
|
||||
await schedule_lottery_draw(context, event.id, event.end_value * 60)
|
||||
return event
|
||||
|
||||
@@ -267,7 +270,7 @@ async def cmd_buy(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
if not context.args:
|
||||
reply = await update.message.reply_text("用法:/buy 商品编号,例如 /buy 1")
|
||||
else:
|
||||
result = await buy_shop_item(update.effective_chat.id, update.effective_user.id, context.args[0])
|
||||
result = await buy_shop_item(update.effective_chat.id, update.effective_user.id, context.args[0], update.effective_user.username or update.effective_user.first_name or str(update.effective_user.id))
|
||||
if result["ok"]:
|
||||
item = result["item"]
|
||||
await Action.create(action="SHOP_BUY", chat_id=update.effective_chat.id, user_id=update.effective_user.id, username=update.effective_user.username, details={"item": item, "chat_id": update.effective_chat.id})
|
||||
@@ -388,7 +391,8 @@ async def handle_lottery_callback(update: Update, context: ContextTypes.DEFAULT_
|
||||
text = (
|
||||
"🎉 抽奖已开奖\n"
|
||||
"━━━━━━━━━━━━\n"
|
||||
f"🎯 奖品:{event.prize}\n"
|
||||
f"🏷️ 标题:{event.title or event.prize}\n"
|
||||
f"🎯 奖品:{event.prize}\n"
|
||||
f"👥 参与人数:{draw['count']} 人\n"
|
||||
f"🏆 中奖用户:@{winner.username or winner.first_name or winner.user_id}"
|
||||
)
|
||||
@@ -431,7 +435,8 @@ async def handle_private_lottery_text(update: Update, context: ContextTypes.DEFA
|
||||
context.user_data.pop("lottery_target_chat_id", None)
|
||||
await update.message.reply_text(
|
||||
"✅ 抽奖活动已发布到群里\n"
|
||||
f"🎯 奖品:{event.prize}\n"
|
||||
f"🏷️ 标题:{event.title or event.prize}\n"
|
||||
f"🎯 奖品:{event.prize}\n"
|
||||
f"✅ 参与条件:{await lottery_condition_text(event)}\n"
|
||||
f"⏱️ 结束条件:{await lottery_end_text(event)}"
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import random
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from db.models import GameScore, LotteryEvent, LotteryParticipant, ShopItem
|
||||
from db.models import GameScore, LotteryEvent, LotteryParticipant, ShopItem, ShopTransaction
|
||||
|
||||
logger = logging.getLogger("spam_guard")
|
||||
|
||||
@@ -142,13 +142,32 @@ async def upsert_shop_item(chat_id: int, item_key: str, name: str, cost: int, de
|
||||
return item
|
||||
|
||||
|
||||
async def buy_shop_item(chat_id: int, user_id: int, item_id: str) -> dict:
|
||||
async def delete_shop_item(chat_id: int, item_key: str) -> bool:
|
||||
deleted = await ShopItem.filter(chat_id=chat_id, item_key=item_key).delete()
|
||||
return bool(deleted)
|
||||
|
||||
|
||||
async def list_shop_transactions(chat_id: int, limit: int = 100) -> list[dict]:
|
||||
rows = await ShopTransaction.filter(chat_id=chat_id).order_by("-created_at").limit(limit).all()
|
||||
return [{"id": r.id, "chat_id": r.chat_id, "user_id": r.user_id, "username": r.username, "item_key": r.item_key, "item_name": r.item_name, "cost": r.cost, "status": r.status, "note": r.note, "created_at": r.created_at} for r in rows]
|
||||
|
||||
|
||||
async def buy_shop_item(chat_id: int, user_id: int, item_id: str, username: str = "") -> dict:
|
||||
items = await list_shop_items(chat_id)
|
||||
item = next((x for x in items if str(x.get("item_key")) == str(item_id) and x.get("enabled", True)), None)
|
||||
if not item:
|
||||
return {"ok": False, "reason": "商品不存在"}
|
||||
if not await deduct_points(chat_id, user_id, int(item["cost"])):
|
||||
return {"ok": False, "reason": "积分不足", "item": item}
|
||||
await ShopTransaction.create(
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
item_key=str(item.get("item_key")),
|
||||
item_name=item.get("name") or "",
|
||||
cost=int(item.get("cost") or 0),
|
||||
status="success",
|
||||
)
|
||||
summary = await get_scores_summary(user_id, chat_id)
|
||||
return {"ok": True, "item": item, "total": summary["total"]}
|
||||
|
||||
@@ -171,12 +190,16 @@ def parse_lottery_condition(raw: str) -> tuple[str, int, str]:
|
||||
|
||||
def parse_lottery_end(raw: str) -> tuple[str, int, str]:
|
||||
text = (raw or "people:3").strip().lower()
|
||||
if text in {"instant", "即开", "即开即中"}:
|
||||
return "instant", 1, "即开即中:首位参与者立即中奖"
|
||||
if text.startswith("people:") or text.startswith("人数:"):
|
||||
value = max(1, int(text.split(":", 1)[1]))
|
||||
return "people", value, f"满 {value} 人自动开奖"
|
||||
if text.startswith("minutes:") or text.startswith("分钟:"):
|
||||
value = max(1, int(text.split(":", 1)[1]))
|
||||
return "minutes", value, f"{value} 分钟后自动开奖"
|
||||
return "time", value, f"{value} 分钟后自动开奖"
|
||||
if text.startswith("time:") or text.startswith("时间:"):
|
||||
return "time_at", 0, f"指定时间开奖:{raw.split(':', 1)[1].strip()}"
|
||||
if text in {"manual", "手动"}:
|
||||
return "manual", 0, "手动开奖"
|
||||
raise ValueError(LOTTERY_END_HELP)
|
||||
@@ -207,9 +230,14 @@ async def create_lottery_event(chat_id: int, creator_id: int, creator_name: str,
|
||||
chat_id=chat_id,
|
||||
creator_id=creator_id,
|
||||
creator_name=creator_name,
|
||||
title=spec.get("title") or spec["prize"],
|
||||
description=spec.get("description") or "",
|
||||
prize=spec["prize"],
|
||||
condition_type=spec["condition_type"],
|
||||
condition_value=spec["condition_value"],
|
||||
draw_type=spec.get("draw_type") or spec["end_type"],
|
||||
draw_at=spec.get("draw_at"),
|
||||
max_participants=spec.get("max_participants") or (spec["end_value"] if spec["end_type"] == "people" else 0),
|
||||
end_type=spec["end_type"],
|
||||
end_value=spec["end_value"],
|
||||
)
|
||||
|
||||
+87
-2
@@ -12,10 +12,10 @@ from fastapi import FastAPI, Query, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from db.models import Action, Ban, GameScore, Message, LotteryEvent, ShopItem
|
||||
from db.models import Action, Ban, GameScore, Message, LotteryEvent, LotteryParticipant, ShopItem, ShopTransaction
|
||||
from services.stats_service import get_daily_stats
|
||||
from services.time_utils import format_dt, from_timestamp, now_tz
|
||||
from services.game_service import list_shop_items, upsert_shop_item, draw_lottery_event
|
||||
from services.game_service import list_shop_items, upsert_shop_item, delete_shop_item, list_shop_transactions, draw_lottery_event
|
||||
|
||||
BASE_DIR = Path(__file__).parent
|
||||
|
||||
@@ -117,6 +117,17 @@ async def api_stats(day: str = Query(default=None, pattern=r"^\d{4}-\d{2}-\d{2}$
|
||||
return JSONResponse(await get_daily_stats(day, chat_id=chat_id))
|
||||
|
||||
|
||||
def parse_draw_at(value):
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=now_tz().tzinfo)
|
||||
return dt
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def normalize_message(row: dict) -> dict:
|
||||
row = dict(row)
|
||||
row["time"] = row.get("created_at")
|
||||
@@ -213,6 +224,17 @@ async def api_save_shop_item(request: Request):
|
||||
return JSONResponse(json_safe({"ok": True, "item": {"id": item.id, "chat_id": item.chat_id, "item_key": item.item_key, "name": item.name, "cost": item.cost, "desc": item.desc, "enabled": item.enabled}}))
|
||||
|
||||
|
||||
@app.delete("/api/shop-items/{item_key}")
|
||||
async def api_delete_shop_item(item_key: str, chat_id: int = 0):
|
||||
ok = await delete_shop_item(chat_id, item_key)
|
||||
return JSONResponse({"ok": ok})
|
||||
|
||||
|
||||
@app.get("/api/shop-transactions")
|
||||
async def api_shop_transactions(chat_id: int = 0, limit: int = Query(default=100, ge=1, le=500)):
|
||||
return JSONResponse(json_safe(await list_shop_transactions(chat_id, limit)))
|
||||
|
||||
|
||||
@app.get("/api/lotteries")
|
||||
async def api_lotteries(chat_id: int | None = None, limit: int = Query(default=100, ge=1, le=300)):
|
||||
qs = LotteryEvent.all()
|
||||
@@ -222,6 +244,69 @@ async def api_lotteries(chat_id: int | None = None, limit: int = Query(default=1
|
||||
return JSONResponse(json_safe(list(rows)))
|
||||
|
||||
|
||||
@app.post("/api/lotteries")
|
||||
async def api_create_lottery(request: Request):
|
||||
data = await request.json()
|
||||
event = await LotteryEvent.create(
|
||||
chat_id=int(data.get("chat_id") or 0),
|
||||
creator_id=int(data.get("creator_id") or 0),
|
||||
creator_name=str(data.get("creator_name") or "web-admin"),
|
||||
title=str(data.get("title") or data.get("prize") or "抽奖活动"),
|
||||
description=str(data.get("description") or ""),
|
||||
prize=str(data.get("prize") or data.get("title") or "奖品"),
|
||||
condition_type=str(data.get("condition_type") or "all"),
|
||||
condition_value=int(data.get("condition_value") or 0),
|
||||
draw_type=str(data.get("draw_type") or data.get("end_type") or "manual"),
|
||||
draw_at=parse_draw_at(data.get("draw_at")),
|
||||
end_type=str(data.get("end_type") or data.get("draw_type") or "manual"),
|
||||
end_value=int(data.get("end_value") or data.get("max_participants") or 0),
|
||||
max_participants=int(data.get("max_participants") or 0),
|
||||
status=str(data.get("status") or "draft"),
|
||||
)
|
||||
return JSONResponse(json_safe({"ok": True, "event": {
|
||||
"id": event.id, "chat_id": event.chat_id, "title": event.title, "description": event.description,
|
||||
"prize": event.prize, "condition_type": event.condition_type, "condition_value": event.condition_value,
|
||||
"draw_type": event.draw_type, "end_type": event.end_type, "end_value": event.end_value,
|
||||
"max_participants": event.max_participants, "draw_at": event.draw_at, "status": event.status, "created_at": event.created_at,
|
||||
}}))
|
||||
|
||||
|
||||
@app.put("/api/lotteries/{event_id}")
|
||||
async def api_update_lottery(event_id: int, request: Request):
|
||||
event = await LotteryEvent.filter(id=event_id).first()
|
||||
if not event:
|
||||
return JSONResponse({"ok": False, "error": "抽奖不存在"}, status_code=404)
|
||||
data = await request.json()
|
||||
for key in ["title", "description", "prize", "condition_type", "draw_type", "end_type", "status"]:
|
||||
if key in data:
|
||||
setattr(event, key, str(data.get(key) or ""))
|
||||
if "draw_at" in data:
|
||||
event.draw_at = parse_draw_at(data.get("draw_at"))
|
||||
for key in ["condition_value", "end_value", "max_participants"]:
|
||||
if key in data:
|
||||
setattr(event, key, int(data.get(key) or 0))
|
||||
await event.save()
|
||||
return JSONResponse(json_safe({"ok": True, "event": {
|
||||
"id": event.id, "chat_id": event.chat_id, "title": event.title, "description": event.description,
|
||||
"prize": event.prize, "condition_type": event.condition_type, "condition_value": event.condition_value,
|
||||
"draw_type": event.draw_type, "end_type": event.end_type, "end_value": event.end_value,
|
||||
"max_participants": event.max_participants, "draw_at": event.draw_at, "status": event.status, "created_at": event.created_at,
|
||||
}}))
|
||||
|
||||
|
||||
@app.delete("/api/lotteries/{event_id}")
|
||||
async def api_delete_lottery(event_id: int):
|
||||
await LotteryParticipant.filter(event_id=event_id).delete()
|
||||
ok = await LotteryEvent.filter(id=event_id).delete()
|
||||
return JSONResponse({"ok": bool(ok)})
|
||||
|
||||
|
||||
@app.get("/api/lotteries/{event_id}/participants")
|
||||
async def api_lottery_participants(event_id: int):
|
||||
rows = await LotteryParticipant.filter(event_id=event_id).order_by("created_at").values()
|
||||
return JSONResponse(json_safe(list(rows)))
|
||||
|
||||
|
||||
@app.post("/api/lotteries/{event_id}/cancel")
|
||||
async def api_cancel_lottery(event_id: int):
|
||||
event = await LotteryEvent.filter(id=event_id).first()
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/static/icon.svg" />
|
||||
<title>TG Spam Guard</title>
|
||||
<script type="module" crossorigin src="/static/assets/index-B7wPhEjS.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/static/assets/index-zEILxixw.css">
|
||||
<script type="module" crossorigin src="/static/assets/index-CfwMEsjZ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/static/assets/index-CCHZcUDy.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Reference in New Issue
Block a user