Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ctfeed.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ async def lifespan(app:FastAPI):
app.include_router(router.user_router)
app.include_router(router.ctf_router)
app.include_router(router.config_router)
app.include_router(router.guild_router)

# index
@app.get("/", tags=["Shirakami Fubuki"])
Expand Down
97 changes: 94 additions & 3 deletions notes/event.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,98 @@
# About Event
## Rules
- **時區都是 utc+0** (要記得轉換)
- ``datetime.now(timezone.utc)``
- ``datetime_obj_with_timezone.astimezone(timezone.utc)``
- 在讀取(即使用``read_event()``)的時候,如果不是確定「只會返回最多一個結果」的情境
- finish_after=``int((datetime.now(timezone.utc) + timedelta(days=settings.DATABASE_SEARCH_DAYS)).timestamp())``
- 我們需要限制讀出的數量,避免 DoS
- 批量讀取 Event
- 使用 ``read_event_many()``
- ``finish_after`` mode: ``finish_after=int((datetime.now(timezone.utc) + timedelta(days=settings.DATABASE_SEARCH_DAYS)).timestamp())``
- 我們需要限制讀出的數量,避免 DoS
- 為避免日後讀取程式碼困難,使用不同的 mode 需要明確傳參(如 finish_before=None,就算是 None 也要傳)
- 請確保在操作 Database 中的 events table 時遵循以下流程,並確保整個流程被包覆在``try...except...finally...``中:
1. 使用 ``src.crud.read_event(..., lock=True, duration=120) 對單個 event 加鎖,並獲取物件
2. (如果有需要,如創建頻道後將 ID 更新到資料庫)操作 Discord Bot
3. 更新資料庫中的資料
4. 在``finally...``區塊中解鎖
5. 如有發生錯誤,在``except...``區塊中 rollback(例如:刪除創建出來的 Discord channel)
- 純讀取不受此限制

## Docs

### ``read_event_one``

#### 用途
- 用於讀取單一 Event(以 ``id`` 為主鍵)
- 可選擇是否同時嘗試加鎖(給後續更新流程使用)

#### 使用方法
- 只讀取(不加鎖)
- ``lock=False``
- 回傳:``(event_db, None)``
- 讀取並嘗試加鎖
- ``lock=True`` 且 ``duration`` 必填
- 成功:回傳 ``(event_db, lock_owner_token)``
- Event 不存在:``NotFoundError``
- Event 已被鎖住:``LockedError``
- ``type`` 可為 ``ctftime`` / ``custom`` / ``None``,用來限制 Event 類型
- ``archived`` 可為 ``True`` / ``False`` / ``None``,用來限制封存狀態

#### 設計說明
- 單筆查詢一律以 ``Event.id`` 為核心條件
- 加鎖模式採用原子條件更新(``locked_until`` + ``locked_by``)避免競態
- 回傳的 ``lock_owner_token`` 需在後續 ``update_event`` / ``unlock_event`` 使用


### ``read_event_many``

#### 用途
- 用於讀取多筆 Event,給 API 列表查詢與背景工作使用
- 支援 ``ctftime`` 與 ``custom`` 兩種查詢模式

#### 使用方法
- ``type=ctftime``
- ``finish_after`` mode
- 只傳 ``finish_after``
- 不能同時傳 ``finish_before``、``limit``、``before_id``
- ``finish_before`` mode
- ``finish_after`` 必須是 ``None``
- ``limit`` 必填且需大於 0
- 第一頁:``finish_before=None`` 且 ``before_id=None``
- 下一頁:帶上前一頁最後一筆的 ``finish`` 和 ``id``(即 ``finish_before``、``before_id``)
- ``type=custom``
- ``limit`` 必填且需大於 0
- 第一頁:``before_id=None``
- 下一頁:帶上前一頁最後一筆 ``id`` 到 ``before_id``
- 不能傳 ``finish_after`` / ``finish_before``
- ``archived`` 可選,用於限制封存狀態

#### 設計說明
- ``ctftime`` 分頁採用複合游標條件:
- 排序:``ORDER BY finish DESC, id DESC``
- 下一頁條件:``finish < finish_before`` 或 ``(finish == finish_before and id < before_id)``
- ``custom`` 分頁採用 ``id`` 游標:
- 排序:``ORDER BY id DESC``
- 下一頁條件:``id < before_id``
- 參數組合會在函式內做嚴格檢查,不合法時拋 ``ValueError``


### ``ctfmenu``(EventMenu / EventDetailMenu)

#### 用途
- Discord 互動式 Event 清單檢視(ctftime / custom)
- 提供分頁、切換 type、查看單筆 Event 詳細資訊

#### 設計說明
- View timeout 設為 ``60s``,避免互動元件長時間掛著
- ``ctftime`` 清單採「首次查詢後快取」:
- 第一次 ``build_embed_and_view()`` 查一次資料庫
- 後續翻頁只吃記憶體快取,不重查 DB
- ``custom`` 清單採 cursor 分頁(``before_id`` + ``limit``):
- 每頁查 ``per_page + 1`` 判斷 ``has_next``
- 使用 page cache(以頁碼快取已讀頁資料)避免回上一頁時被新資料擠動
- ``EventDetailMenu`` 使用 ``read_event_one(lock=False, ...)`` 讀單筆,找不到時回傳 ``Event not found``

#### 常見坑
- ``custom`` 分頁如果每次都重查 DB(不做頁面快取),新資料插入後會造成頁面漂移或看起來「有些 event 被擠掉」
- ``ctftime`` 模式若每次翻頁都重查 DB,會有不必要的負擔;此場景改用快取較穩定
- ``read_event_many`` 需明確傳 mode 參數(即使是 ``None`` 也傳)以提升可讀性並符合本專案規範
- ``read_event_one`` / lock 流程內部使用 ``session.begin()``,caller 不要在外層再包 transaction 以避免巢狀交易風險
146 changes: 40 additions & 106 deletions src/backend/channel_op.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Optional, Dict, Any
from typing import Optional, Dict, Any, Tuple
import logging

from sqlalchemy.ext.asyncio import AsyncSession
Expand All @@ -13,16 +13,36 @@
from src.utils import get_category
from src.utils import ctf_api
from src.utils import embed_creator
from src.bot import get_bot
from src.bot import get_guild
from src import crud

# channel_op = "event_op"

# logging
logger = logging.getLogger("uvicorn")

# utils
async def read_event_one_wrapper(session:AsyncSession, event_db_id:int) -> Tuple[model.Event, str]:
try:
event_db, lock_owner_token = await crud.read_event_one(
session=session,
lock=True, duration=120,
archived=False, # ensoure the Event isn't archived
id=event_db_id
)
except crud.NotFoundError:
raise HTTPException(404, f"Event (id={event_db_id}) not found (archived, or invalid id)")
except crud.LockedError:
raise HTTPException(423, F"Event (id={event_db_id}) was locked. Try again later.")
except Exception as e:
logger.error(f"Can't get and lock Event (id={event_db_id}): {str(e)}")
raise HTTPException(500, f"Can't get and lock Event (id={event_db_id})")

return event_db, lock_owner_token


# functions
async def _create_channel(session:AsyncSession, member:discord.Member, event_db_id:int, lock_owner_token:str):
async def _create_channel(session:AsyncSession, member:discord.Member, event_db:model.Event, lock_owner_token:str) -> model.Event:
# 在這個 function 有 exception 就直接 raise 出來
channel:Optional[discord.TextChannel] = None
event_api:Optional[Dict[str, Any]] = None
Expand All @@ -31,10 +51,7 @@ async def _create_channel(session:AsyncSession, member:discord.Member, event_db_
log_msg:str = ""

# get guild
bot = await get_bot()
if (guild := bot.get_guild(settings.GUILD_ID)) is None:
logger.critical(f"Guild (id={settings.GUILD_ID}) not found")
raise HTTPException(500, f"Guild (id={settings.GUILD_ID}) not found")
guild = get_guild()

# get category
if (ctf_channel_category := get_category.get_category(guild, settings.CTF_CHANNEL_CATEGORY_ID)) is None:
Expand All @@ -43,23 +60,13 @@ async def _create_channel(session:AsyncSession, member:discord.Member, event_db_

try:
async with session.begin():
# get a new event_db
events_db = await crud.read_event(
session,
id=event_db_id,
archived=False, # ensure the event isn't archived
lock_owner_token=lock_owner_token
)
if len(events_db) != 1:
raise RuntimeError(f"Event (id={event_db_id}) not found")
event_db = events_db[0]
ctftime_event = True if event_db.event_id is not None else False

# check channel
if (channel_id := event_db.channel_id) is not None and \
guild.get_channel(channel_id) is not None:
# exists -> no need to create
return
return event_db

if ctftime_event:
events_api = await ctf_api.fetch_ctf_events(event_db.event_id)
Expand Down Expand Up @@ -107,49 +114,35 @@ async def _create_channel(session:AsyncSession, member:discord.Member, event_db_
except Exception as e:
logger.error(f"fail to send notification to channel (id={channel.id}): {str(e)}")
# ignore exception

return

return event_db

async def _join_channel(session:AsyncSession, member:discord.Member, event_db_id:int, lock_owner_token:str):

async def _join_channel(session:AsyncSession, member:discord.Member, event_db:model.Event, lock_owner_token:str):
# 在這個 function 有 exception 就直接 raise 出來
# get guild
bot = await get_bot()
if (guild := bot.get_guild(settings.GUILD_ID)) is None:
logger.critical(f"Guild (id={settings.GUILD_ID}) not found")
raise HTTPException(500, f"Guild (id={settings.GUILD_ID}) not found")
guild = get_guild()

joined_channel = False # joined channel in Discord, but not in database
joined = False # joined channel in Discord and database
log_msg:str = ""
try:
async with session.begin():
# get a new event_db
events_db = await crud.read_event(
session,
id=event_db_id,
archived=False, # ensure the Event isn't archived
lock_owner_token=lock_owner_token
)
if len(events_db) != 1:
raise RuntimeError(f"Event (id={event_db_id}) not found")
event_db = events_db[0]

# check channel
if (channel_id := event_db.channel_id) is None or \
(channel := guild.get_channel(channel_id)) is None:
raise RuntimeError(f"TextChannel for Event (id={event_db_id}) not found")
raise RuntimeError(f"TextChannel for Event (id={event_db.id}) not found")

# join channel
await channel.set_permissions(member, view_channel=True)
joined_channel = True

# update database
try:
await crud.join_event(session, event_db_id, member.id, lock_owner_token)
await crud.join_event(session, event_db.id, member.id, lock_owner_token)
except IntegrityError:
# ignore
raise HTTPException(409, f"The user (discord_id={member.id}) has joined the Event (id={event_db_id})")
raise HTTPException(409, f"The user (discord_id={member.id}) has joined the Event (id={event_db.id})")
except Exception:
raise
joined = True
Expand Down Expand Up @@ -196,22 +189,14 @@ async def create_and_join_channel(member:discord.Member, event_db_id:int):
lock_owner_token:Optional[str] = None
async with database.with_get_db() as session:
# try to lock event
try:
lock_owner_token = await crud.try_lock_event(session, event_db_id, 120)
except crud.LockedError:
raise HTTPException(423, f"Event (id={event_db_id}) was locked. Try again later.")
except crud.NotFoundError:
raise HTTPException(404, f"Event (id={event_db_id}) not found")
except Exception as e:
logger.error(f"Can't lock Event (id={event_db_id}): {str(e)}")
raise HTTPException(f"Can't lock Event (id={event_db_id}): {str(e)}")
event_db, lock_owner_token = await read_event_one_wrapper(session, event_db_id)

try:
# try to create channel
await _create_channel(session, member, event_db_id, lock_owner_token)
event_db = await _create_channel(session, member, event_db, lock_owner_token)

# join channel
await _join_channel(session, member, event_db_id, lock_owner_token)
await _join_channel(session, member, event_db, lock_owner_token)
except Exception as e:
if isinstance(e, HTTPException):
raise
Expand Down Expand Up @@ -239,41 +224,17 @@ async def archive_event(event_db_id:int, reason:str):
event_db_returning = {}

# get guild
bot = await get_bot()
if (guild := bot.get_guild(settings.GUILD_ID)) is None:
logger.critical(f"Guild (id={settings.GUILD_ID}) not found")
raise HTTPException(500, f"Guild (id={settings.GUILD_ID}) not found")
guild = get_guild()

# get archive category
if (archive_category := get_category.get_category(guild, settings.ARCHIVE_CATEGORY_ID)) is None:
logger.critical(f"Archive Category (id={settings.ARCHIVE_CATEGORY_ID}) not found")
raise HTTPException(500, f"Archive Category (id={settings.ARCHIVE_CATEGORY_ID}) not found")

async with database.with_get_db() as session:
# try to lock the Event
try:
lock_owner_token = await crud.try_lock_event(session, event_db_id, 120)
except crud.NotFoundError:
raise HTTPException(404, f"Event (id={event_db_id}) not found")
except crud.LockedError:
raise HTTPException(423, f"Event (id={event_db_id}) was locked. Try again later.")
except Exception as e:
logger.error(f"Can't lock Event (id={event_db_id}): {str(e)}")
raise HTTPException(500, f"Can't lock Event (id={event_db_id})")

event_db, lock_owner_token = await read_event_one_wrapper(session, event_db_id)
try:
async with session.begin():
# get a new event_db
events_db = await crud.read_event(
session=session,
archived=False, # ensure the Event isn't archived
id=event_db_id,
lock_owner_token=lock_owner_token,
)
if len(events_db) != 1:
raise RuntimeError(f"Event (id={event_db_id}) not found")
event_db = events_db[0]

# update database
event_db:model.Event = await crud.update_event(
session=session,
Expand Down Expand Up @@ -360,41 +321,17 @@ async def link_event_to_channel(event_db_id:int, channel_id:int):
lock_owner_token = None

# get guild
bot = await get_bot()
if (guild := bot.get_guild(settings.GUILD_ID)) is None:
logger.critical(f"Guild (id={settings.GUILD_ID}) not found")
raise HTTPException(500, f"Guild (id={settings.GUILD_ID}) not found")
guild = get_guild()

# get channel
if (channel := guild.get_channel(channel_id)) is None or \
not isinstance(channel, discord.TextChannel):
raise HTTPException(400, f"Channel (id={channel_id}) not found")

async with database.with_get_db() as session:
# try to lock the Event
try:
lock_owner_token = await crud.try_lock_event(session, event_db_id, 120)
except crud.NotFoundError:
raise HTTPException(404, f"Event (id={event_db_id}) not found")
except crud.LockedError:
raise HTTPException(423, f"Event (id={event_db_id}) was locked. Try again later.")
except Exception as e:
logger.error(f"Can't lock Event (id={event_db_id}): {str(e)}")
raise HTTPException(500, f"Can't lock Event (id={event_db_id})")

event_db, lock_owner_token = await read_event_one_wrapper(session, event_db_id)
try:
async with session.begin():
# get a new event_db
events_db = await crud.read_event(
session=session,
archived=False, # ensure the Event isn't archived
id=event_db_id,
lock_owner_token=lock_owner_token
)
if len(events_db) != 1:
raise RuntimeError(f"Event (id={event_db_id}) not found")
event_db = events_db[0]

# update database
event_db:model.Event = await crud.update_event(
session=session,
Expand Down Expand Up @@ -428,10 +365,7 @@ async def create_custom_event(title:str):
:raise HTTPException:
"""
# get guild
bot = await get_bot()
if (guild := bot.get_guild(settings.GUILD_ID)) is None:
logger.critical(f"Guild (id={settings.GUILD_ID}) not found")
raise HTTPException(500, f"Guild (id={settings.GUILD_ID}) not found")
guild = get_guild()

# create the custom event in database
event_db_id = None
Expand Down
Loading
Loading