|
@@ -9,10 +9,13 @@ from html import escape
|
|
|
from typing import Any
|
|
from typing import Any
|
|
|
from zoneinfo import ZoneInfo
|
|
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 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 (
|
|
from wbb.utils.dbgiveaway import (
|
|
|
STATUS_CANCELED,
|
|
STATUS_CANCELED,
|
|
|
STATUS_CANCELING,
|
|
STATUS_CANCELING,
|
|
@@ -30,11 +33,15 @@ from wbb.utils.dbgiveaway import (
|
|
|
finish_giveaway,
|
|
finish_giveaway,
|
|
|
get_giveaway,
|
|
get_giveaway,
|
|
|
get_participant,
|
|
get_participant,
|
|
|
|
|
+ giveawaysdb,
|
|
|
|
|
+ is_giveaway_banned,
|
|
|
list_participants,
|
|
list_participants,
|
|
|
mark_participant_refunded,
|
|
mark_participant_refunded,
|
|
|
|
|
+ participantsdb,
|
|
|
record_reroll,
|
|
record_reroll,
|
|
|
remove_participant,
|
|
remove_participant,
|
|
|
save_pending_winners,
|
|
save_pending_winners,
|
|
|
|
|
+ ticket_ordersdb,
|
|
|
update_running_giveaway,
|
|
update_running_giveaway,
|
|
|
utc_now,
|
|
utc_now,
|
|
|
)
|
|
)
|
|
@@ -132,28 +139,44 @@ async def render_giveaway(giveaway: dict[str, Any]) -> str:
|
|
|
if minimum:
|
|
if minimum:
|
|
|
text.append(f"<b>最低积分:</b>{minimum}")
|
|
text.append(f"<b>最低积分:</b>{minimum}")
|
|
|
if cost:
|
|
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:
|
|
if reward:
|
|
|
text.append(f"<b>参与奖励:</b>+{reward}")
|
|
text.append(f"<b>参与奖励:</b>+{reward}")
|
|
|
return "\n".join(text)
|
|
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(
|
|
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(
|
|
InlineKeyboardButton(
|
|
|
"查看参与者",
|
|
"查看参与者",
|
|
|
callback_data=f"giveaway_participants:{giveaway_id}",
|
|
callback_data=f"giveaway_participants:{giveaway_id}",
|
|
|
)
|
|
)
|
|
|
],
|
|
],
|
|
|
- ]
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ ])
|
|
|
|
|
+ return InlineKeyboardMarkup(rows)
|
|
|
|
|
|
|
|
|
|
|
|
|
async def refresh_giveaway_message(giveaway: dict[str, Any]) -> bool:
|
|
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),
|
|
int(message_id),
|
|
|
await render_giveaway(giveaway),
|
|
await render_giveaway(giveaway),
|
|
|
parse_mode=ParseMode.HTML,
|
|
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:
|
|
except Exception:
|
|
|
return False
|
|
return False
|
|
@@ -247,9 +270,15 @@ def pick_winners(
|
|
|
for participant in participants
|
|
for participant in participants
|
|
|
if int(participant["user_id"]) not in picked_user_ids
|
|
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:
|
|
for participant in picked:
|
|
|
user_id = int(participant["user_id"])
|
|
user_id = int(participant["user_id"])
|
|
|
picked_user_ids.add(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"],
|
|
giveaway["chat_id"],
|
|
|
await render_giveaway(giveaway),
|
|
await render_giveaway(giveaway),
|
|
|
parse_mode=ParseMode.HTML,
|
|
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,
|
|
disable_web_page_preview=True,
|
|
|
)
|
|
)
|
|
|
except Exception:
|
|
except Exception:
|
|
@@ -313,14 +342,6 @@ async def create_and_publish_giveaway(**values: Any) -> dict[str, Any]:
|
|
|
return giveaway
|
|
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(
|
|
async def join_giveaway(
|
|
|
giveaway_id: str,
|
|
giveaway_id: str,
|
|
|
*,
|
|
*,
|
|
@@ -344,10 +365,16 @@ async def join_giveaway(
|
|
|
existing = await get_participant(giveaway_id, user_id)
|
|
existing = await get_participant(giveaway_id, user_id)
|
|
|
if existing:
|
|
if existing:
|
|
|
return "duplicate", giveaway
|
|
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))
|
|
minimum = int(giveaway.get("minimum_points", 0))
|
|
|
entry_cost = int(giveaway.get("entry_cost", 0))
|
|
entry_cost = int(giveaway.get("entry_cost", 0))
|
|
@@ -417,6 +444,187 @@ async def join_giveaway(
|
|
|
return result, 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(
|
|
async def _entry_attempt_keys(
|
|
|
giveaway_id: str, user_id: int
|
|
giveaway_id: str, user_id: int
|
|
|
) -> tuple[str, str, bool]:
|
|
) -> tuple[str, str, bool]:
|
|
@@ -444,8 +652,9 @@ async def _refund_entry(
|
|
|
display_name: str | None,
|
|
display_name: str | None,
|
|
|
key_suffix: str,
|
|
key_suffix: str,
|
|
|
idempotency_key: str | None = None,
|
|
idempotency_key: str | None = None,
|
|
|
|
|
+ amount: int | None = None,
|
|
|
) -> bool:
|
|
) -> 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:
|
|
if entry_cost <= 0:
|
|
|
return False
|
|
return False
|
|
|
_, created = await adjust_points(
|
|
_, created = await adjust_points(
|
|
@@ -528,12 +737,67 @@ async def finish_and_publish_giveaway(
|
|
|
return False, "该抽奖已经完成开奖。", existing
|
|
return False, "该抽奖已经完成开奖。", existing
|
|
|
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)
|
|
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:
|
|
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
|
|
giveaway = await save_pending_winners(giveaway_id, pending) or giveaway
|
|
|
winners = list(giveaway.get("pending_winners", []))
|
|
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)
|
|
marked = await finish_giveaway(giveaway_id, winners)
|
|
|
if not marked:
|
|
if not marked:
|
|
|
current = await get_giveaway(giveaway_id)
|
|
current = await get_giveaway(giveaway_id)
|
|
@@ -552,10 +816,23 @@ async def finish_and_publish_giveaway(
|
|
|
async def publish_finished(
|
|
async def publish_finished(
|
|
|
giveaway: dict[str, Any], winners: list[dict[str, Any]]
|
|
giveaway: dict[str, Any], winners: list[dict[str, Any]]
|
|
|
) -> None:
|
|
) -> None:
|
|
|
|
|
+ giveaway = await get_giveaway(giveaway["giveaway_id"]) or giveaway
|
|
|
chat_id = int(giveaway["chat_id"])
|
|
chat_id = int(giveaway["chat_id"])
|
|
|
message_id = giveaway.get("message_id")
|
|
message_id = giveaway.get("message_id")
|
|
|
text = render_winners(giveaway, winners)
|
|
text = render_winners(giveaway, winners)
|
|
|
result_message = None
|
|
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:
|
|
if message_id:
|
|
|
with suppress(Exception):
|
|
with suppress(Exception):
|
|
|
await app.edit_message_text(
|
|
await app.edit_message_text(
|
|
@@ -565,20 +842,26 @@ async def publish_finished(
|
|
|
parse_mode=ParseMode.HTML,
|
|
parse_mode=ParseMode.HTML,
|
|
|
reply_markup=None,
|
|
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:
|
|
if result_message is None:
|
|
|
with suppress(Exception):
|
|
with suppress(Exception):
|
|
|
result_message = await app.send_message(
|
|
result_message = await app.send_message(
|
|
|
chat_id, text, parse_mode=ParseMode.HTML
|
|
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:
|
|
try:
|
|
|
await app.pin_chat_message(
|
|
await app.pin_chat_message(
|
|
|
chat_id,
|
|
chat_id,
|
|
@@ -597,6 +880,16 @@ async def publish_finished(
|
|
|
# If Telegram refuses deletion, at least remove the stale pinned entry.
|
|
# If Telegram refuses deletion, at least remove the stale pinned entry.
|
|
|
with suppress(Exception):
|
|
with suppress(Exception):
|
|
|
await app.unpin_chat_message(chat_id, int(message_id))
|
|
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:
|
|
for winner in winners:
|
|
|
with suppress(Exception):
|
|
with suppress(Exception):
|
|
|
await app.send_message(
|
|
await app.send_message(
|
|
@@ -612,6 +905,7 @@ async def publish_finished(
|
|
|
async def cancel_and_refund_giveaway(
|
|
async def cancel_and_refund_giveaway(
|
|
|
giveaway_id: str, *, chat_id: int, publish: bool = True
|
|
giveaway_id: str, *, chat_id: int, publish: bool = True
|
|
|
) -> tuple[bool, str, dict[str, Any] | None]:
|
|
) -> tuple[bool, str, dict[str, Any] | None]:
|
|
|
|
|
+ await reconcile_pending_ticket_orders()
|
|
|
async with _giveaway_locks[giveaway_id]:
|
|
async with _giveaway_locks[giveaway_id]:
|
|
|
giveaway = await claim_giveaway_cancel(giveaway_id, chat_id)
|
|
giveaway = await claim_giveaway_cancel(giveaway_id, chat_id)
|
|
|
if not giveaway:
|
|
if not giveaway:
|
|
@@ -630,6 +924,9 @@ async def cancel_and_refund_giveaway(
|
|
|
first_name=participant.get("first_name"),
|
|
first_name=participant.get("first_name"),
|
|
|
display_name=participant.get("display_name"),
|
|
display_name=participant.get("display_name"),
|
|
|
key_suffix="cancel",
|
|
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)
|
|
await finalize_giveaway_cancel(giveaway_id)
|
|
|
giveaway = {**giveaway, "status": STATUS_CANCELED}
|
|
giveaway = {**giveaway, "status": STATUS_CANCELED}
|
|
@@ -675,6 +972,9 @@ async def remove_and_optionally_refund_participant(
|
|
|
first_name=participant.get("first_name"),
|
|
first_name=participant.get("first_name"),
|
|
|
display_name=participant.get("display_name"),
|
|
display_name=participant.get("display_name"),
|
|
|
key_suffix="participant-removed",
|
|
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)
|
|
await refresh_giveaway_message(giveaway)
|
|
|
return True
|
|
return True
|
|
@@ -717,7 +1017,17 @@ async def reroll_giveaway(
|
|
|
for participant in await list_participants(giveaway_id)
|
|
for participant in await list_participants(giveaway_id)
|
|
|
if int(participant["user_id"]) not in excluded
|
|
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(
|
|
await record_reroll(
|
|
|
giveaway_id,
|
|
giveaway_id,
|
|
|
winners,
|
|
winners,
|