feat(detector): 新增封禁谱面表并检查可疑谱面

- 在数据库中添加了封禁谱面表(banned_beatmaps)
- 实现了检查可疑谱面的功能,将不符合规范的谱面添加到封禁列表
- 更新了相关的模型和迁移文件
This commit is contained in:
chenjintang-shrimp
2025-08-15 08:05:02 +00:00
parent d87839f86e
commit 7c7c68c163
6 changed files with 158 additions and 2 deletions

View File

@@ -1,7 +1,9 @@
from __future__ import annotations from __future__ import annotations
import math import math
import os
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import zipfile
from app.config import settings from app.config import settings
from app.log import logger from app.log import logger
@@ -9,6 +11,12 @@ from app.models.beatmap import BeatmapAttributes
from app.models.mods import APIMod from app.models.mods import APIMod
from app.models.score import GameMode from app.models.score import GameMode
import httpx
from osupyparser import OsuFile
from osupyparser.osu.objects import Slider
from sqlmodel import Session, create_engine, select
from sqlmodel.ext.asyncio.session import AsyncSession
try: try:
import rosu_pp_py as rosu import rosu_pp_py as rosu
except ImportError: except ImportError:
@@ -78,9 +86,21 @@ def calculate_pp(
) )
attrs = perf.calculate(map) attrs = perf.calculate(map)
pp = attrs.pp pp = attrs.pp
engine = create_engine(settings.database_url)
from app.database.beatmap import BannedBeatmaps
beatmap_banned = False
with Session(engine) as session:
beatmap_id = session.exec(
select(BannedBeatmaps).where(BannedBeatmaps.beatmap_id == score.beatmap_id)
).first()
if beatmap_id:
beatmap_banned = True
# mrekk bp1: 2048pp; ppy-sb top1 rxbp1: 2198pp # mrekk bp1: 2048pp; ppy-sb top1 rxbp1: 2198pp
if settings.suspicious_score_check and ( if settings.suspicious_score_check and (
(attrs.difficulty.stars > 25 and score.accuracy < 0.8) or pp > 2300 beatmap_banned
or (attrs.difficulty.stars > 25 and score.accuracy < 0.8)
or pp > 2300
): ):
logger.warning( logger.warning(
f"User {score.user_id} played {score.beatmap_id} with {pp=} " f"User {score.user_id} played {score.beatmap_id} with {pp=} "
@@ -225,6 +245,24 @@ def calculate_score_to_level(total_score: int) -> float:
99999999999, 99999999999,
99999999999, 99999999999,
99999999999, 99999999999,
99999999999,
99999999999,
99999999999,
99999999999,
99999999999,
99999999999,
99999999999,
99999999999,
99999999999,
99999999999,
99999999999,
99999999999,
99999999999,
99999999999,
99999999999,
99999999999,
99999999999,
99999999999,
] ]
remaining_score = total_score remaining_score = total_score
@@ -251,3 +289,50 @@ def calculate_weighted_pp(pp: float, index: int) -> float:
def calculate_weighted_acc(acc: float, index: int) -> float: def calculate_weighted_acc(acc: float, index: int) -> float:
return calculate_pp_weight(index) * acc if acc > 0 else 0.0 return calculate_pp_weight(index) * acc if acc > 0 else 0.0
async def get_suspscious_beatmap(beatmapset_id: int, session: AsyncSession):
url = (
f"https://txy1.sayobot.cn/beatmaps/download/novideo/{beatmapset_id}?server=auto"
)
async with httpx.AsyncClient() as client:
resp = await client.get(url)
if resp.status_code == 200:
import aiofiles
async with aiofiles.open(f"temp_beatmaps/{beatmapset_id}.osz", "wb") as f:
await f.write(resp.content)
with zipfile.ZipFile(f"temp_beatmaps/{beatmapset_id}.osz", "r") as beatmap_ref:
beatmap_ref.extractall(f"temp_beatmaps/{beatmapset_id}")
os.remove(f"temp_beatmaps/{beatmapset_id}.osz")
all_osu_files = []
for root, dirs, files in os.walk(f"temp_beatmaps/{beatmapset_id}"):
for name in files:
if name.endswith(".osu"):
all_osu_files.append(os.path.join(root, name))
for file in all_osu_files:
osufile = OsuFile(file).parse_file()
for obj in osufile.hit_objects:
if obj.pos.x < 0 or obj.pos.y < 0 or obj.pos.x > 512 or obj.pos.y > 384:
# 延迟导入以解决循环导入问题
from app.database.beatmap import BannedBeatmaps
session.add(
BannedBeatmaps(id=osufile.beatmap_id, beatmap_id=osufile.beatmap_id)
)
break
if type(obj) is Slider:
for point in obj.points:
if point.x < 0 or point.y < 0 or point.x > 512 or point.y > 384:
# 延迟导入以解决循环导入问题
from app.database.beatmap import BannedBeatmaps
session.add(
BannedBeatmaps(
id=osufile.beatmap_id, beatmap_id=osufile.beatmap_id
)
)
break
os.remove(file)
os.remove(f"temp_beatmaps/{beatmapset_id}")
return None

View File

@@ -3,7 +3,6 @@ from datetime import datetime
import hashlib import hashlib
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from app.calculator import calculate_beatmap_attribute
from app.config import settings from app.config import settings
from app.models.beatmap import BeatmapAttributes, BeatmapRankStatus from app.models.beatmap import BeatmapAttributes, BeatmapRankStatus
from app.models.mods import APIMod from app.models.mods import APIMod
@@ -202,6 +201,12 @@ class BeatmapResp(BeatmapBase):
return cls.model_validate(beatmap_) return cls.model_validate(beatmap_)
class BannedBeatmaps(SQLModel, table=True):
__tablename__ = "banned_beatmaps" # pyright: ignore[reportAssignmentType]
id: int = Field(primary_key=True, index=True)
beatmap_id: int = Field(index=True)
async def calculate_beatmap_attributes( async def calculate_beatmap_attributes(
beatmap_id: int, beatmap_id: int,
ruleset: GameMode, ruleset: GameMode,
@@ -216,6 +221,9 @@ async def calculate_beatmap_attributes(
if await redis.exists(key): if await redis.exists(key):
return BeatmapAttributes.model_validate_json(await redis.get(key)) # pyright: ignore[reportArgumentType] return BeatmapAttributes.model_validate_json(await redis.get(key)) # pyright: ignore[reportArgumentType]
resp = await fetcher.get_or_fetch_beatmap_raw(redis, beatmap_id) resp = await fetcher.get_or_fetch_beatmap_raw(redis, beatmap_id)
# 延迟导入以解决循环导入问题
from app.calculator import calculate_beatmap_attribute
attr = await asyncio.get_event_loop().run_in_executor( attr = await asyncio.get_event_loop().run_in_executor(
None, calculate_beatmap_attribute, resp, ruleset, mods_ None, calculate_beatmap_attribute, resp, ruleset, mods_
) )

View File

@@ -183,6 +183,10 @@ class Beatmapset(AsyncAttrs, BeatmapsetBase, table=True):
if not beatmapset: if not beatmapset:
resp = await fetcher.get_beatmapset(sid) resp = await fetcher.get_beatmapset(sid)
beatmapset = await cls.from_resp(session, resp) beatmapset = await cls.from_resp(session, resp)
# 检查可疑谱面
from app.calculator import get_suspscious_beatmap
await get_suspscious_beatmap(sid, session)
return beatmapset return beatmapset

View File

@@ -0,0 +1,50 @@
"""add table banned_beatmaps
Revision ID: 9f6b27e8ea51
Revises: 951a2188e691
Create Date: 2025-08-15 07:23:25.645360
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "9f6b27e8ea51"
down_revision: str | Sequence[str] | None = "951a2188e691"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"banned_beatmaps",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("beatmap_id", sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_banned_beatmaps_beatmap_id"),
"banned_beatmaps",
["beatmap_id"],
unique=False,
)
op.create_index(
op.f("ix_banned_beatmaps_id"), "banned_beatmaps", ["id"], unique=False
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_banned_beatmaps_id"), table_name="banned_beatmaps")
op.drop_index(op.f("ix_banned_beatmaps_beatmap_id"), table_name="banned_beatmaps")
op.drop_table("banned_beatmaps")
# ### end Alembic commands ###

View File

@@ -16,6 +16,7 @@ dependencies = [
"httpx>=0.28.1", "httpx>=0.28.1",
"loguru>=0.7.3", "loguru>=0.7.3",
"msgpack-lazer-api", "msgpack-lazer-api",
"osupyparser>=1.0.7",
"passlib[bcrypt]>=1.7.4", "passlib[bcrypt]>=1.7.4",
"pillow>=11.3.0", "pillow>=11.3.0",
"pydantic-settings>=2.10.1", "pydantic-settings>=2.10.1",

8
uv.lock generated
View File

@@ -822,6 +822,7 @@ dependencies = [
{ name = "httpx" }, { name = "httpx" },
{ name = "loguru" }, { name = "loguru" },
{ name = "msgpack-lazer-api" }, { name = "msgpack-lazer-api" },
{ name = "osupyparser" },
{ name = "passlib", extra = ["bcrypt"] }, { name = "passlib", extra = ["bcrypt"] },
{ name = "pillow" }, { name = "pillow" },
{ name = "pydantic", extra = ["email"] }, { name = "pydantic", extra = ["email"] },
@@ -857,6 +858,7 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", specifier = ">=0.28.1" },
{ name = "loguru", specifier = ">=0.7.3" }, { name = "loguru", specifier = ">=0.7.3" },
{ name = "msgpack-lazer-api", editable = "packages/msgpack_lazer_api" }, { name = "msgpack-lazer-api", editable = "packages/msgpack_lazer_api" },
{ name = "osupyparser", specifier = ">=1.0.7" },
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
{ name = "pillow", specifier = ">=11.3.0" }, { name = "pillow", specifier = ">=11.3.0" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.5.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.5.0" },
@@ -879,6 +881,12 @@ dev = [
{ name = "types-aioboto3", extras = ["aioboto3", "essential"], specifier = ">=15.0.0" }, { name = "types-aioboto3", extras = ["aioboto3", "essential"], specifier = ">=15.0.0" },
] ]
[[package]]
name = "osupyparser"
version = "1.0.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/6b/7f567c2acd1f2028603353da40ad7411bb47754994552d3f0c4cfa6703f9/OsuPyParser-1.0.7.tar.gz", hash = "sha256:67f530c31dd5c288c8fff8f583c899c673536681f8cc3699d0afc5e4d8c2b1ff", size = 9095, upload-time = "2021-08-29T23:48:22.752Z" }
[[package]] [[package]]
name = "passlib" name = "passlib"
version = "1.7.4" version = "1.7.4"