diff --git a/.env.client b/.env.client new file mode 100644 index 0000000..331898d --- /dev/null +++ b/.env.client @@ -0,0 +1,4 @@ +# osu! API 客户端配置 +OSU_CLIENT_ID=5 +OSU_CLIENT_SECRET=FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk +OSU_API_URL=http://localhost:8000 diff --git a/.gitignore b/.gitignore index b7faf40..bb18dc3 100644 --- a/.gitignore +++ b/.gitignore @@ -205,3 +205,4 @@ cython_debug/ marimo/_static/ marimo/_lsp/ __marimo__/ +bancho.py-master/* \ No newline at end of file diff --git a/DATA_SYNC_GUIDE.md b/DATA_SYNC_GUIDE.md new file mode 100644 index 0000000..86188eb --- /dev/null +++ b/DATA_SYNC_GUIDE.md @@ -0,0 +1,140 @@ +# Lazer API 数据同步指南 + +本指南将帮助您将现有的 bancho.py 数据库数据同步到新的 Lazer API 专用表中。 + +## 文件说明 + +1. **`migrations/add_missing_fields.sql`** - 创建 Lazer API 专用表结构 +2. **`migrations/sync_legacy_data.sql`** - 数据同步脚本 +3. **`sync_data.py`** - 交互式数据同步工具 +4. **`quick_sync.py`** - 快速同步脚本(使用项目配置) + +## 使用方法 + +### 方法一:快速同步(推荐) + +如果您已经配置好了项目的数据库连接,可以直接使用快速同步脚本: + +```bash +python quick_sync.py +``` + +此脚本会: +1. 自动读取项目配置中的数据库连接信息 +2. 创建 Lazer API 专用表结构 +3. 同步现有数据到新表 + +### 方法二:交互式同步 + +如果需要使用不同的数据库连接配置: + +```bash +python sync_data.py +``` + +此脚本会: +1. 交互式地询问数据库连接信息 +2. 检查必要表是否存在 +3. 显示详细的同步过程和结果 + +### 方法三:手动执行 SQL + +如果您熟悉 SQL 操作,可以手动执行: + +```bash +# 1. 创建表结构 +mysql -u username -p database_name < migrations/add_missing_fields.sql + +# 2. 同步数据 +mysql -u username -p database_name < migrations/sync_legacy_data.sql +``` + +## 同步内容 + +### 创建的新表 + +- `lazer_user_profiles` - 用户扩展资料 +- `lazer_user_countries` - 用户国家信息 +- `lazer_user_kudosu` - 用户 Kudosu 统计 +- `lazer_user_counts` - 用户各项计数统计 +- `lazer_user_statistics` - 用户游戏统计(按模式) +- `lazer_user_achievements` - 用户成就 +- `lazer_oauth_tokens` - OAuth 访问令牌 +- 其他相关表... + +### 同步的数据 + +1. **用户基本信息** + - 从 `users` 表同步基本资料 + - 自动转换时间戳格式 + - 设置合理的默认值 + +2. **游戏统计** + - 从 `stats` 表同步各模式的游戏数据 + - 计算命中精度和其他衍生统计 + +3. **用户成就** + - 从 `user_achievements` 表同步成就数据(如果存在) + +## 注意事项 + +1. **安全性** + - 脚本只会创建新表和插入数据 + - 不会修改或删除现有的原始表数据 + - 使用 `ON DUPLICATE KEY UPDATE` 避免重复插入 + +2. **兼容性** + - 兼容现有的 bancho.py 数据库结构 + - 支持标准的 osu! 数据格式 + +3. **性能** + - 大量数据可能需要较长时间 + - 建议在维护窗口期间执行 + +## 故障排除 + +### 常见错误 + +1. **"Unknown column" 错误** + ``` + ERROR 1054: Unknown column 'users.is_active' in 'field list' + ``` + **解决方案**: 确保先执行了 `add_missing_fields.sql` 创建表结构 + +2. **"Table doesn't exist" 错误** + ``` + ERROR 1146: Table 'database.users' doesn't exist + ``` + **解决方案**: 确认数据库中存在 bancho.py 的原始表 + +3. **连接错误** + ``` + ERROR 2003: Can't connect to MySQL server + ``` + **解决方案**: 检查数据库连接配置和权限 + +### 验证同步结果 + +同步完成后,可以执行以下查询验证结果: + +```sql +-- 检查同步的用户数量 +SELECT COUNT(*) FROM lazer_user_profiles; + +-- 查看样本数据 +SELECT + u.id, u.name, + lup.playmode, lup.is_supporter, + lus.pp, lus.play_count +FROM users u +LEFT JOIN lazer_user_profiles lup ON u.id = lup.user_id +LEFT JOIN lazer_user_statistics lus ON u.id = lus.user_id AND lus.mode = 'osu' +LIMIT 5; +``` + +## 支持 + +如果遇到问题,请: +1. 检查日志文件 `data_sync.log` +2. 确认数据库权限 +3. 验证原始表数据完整性 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f105e8f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.11-slim + +WORKDIR /app + +# 安装系统依赖 +RUN apt-get update && apt-get install -y \ + gcc \ + pkg-config \ + default-libmysqlclient-dev \ + && rm -rf /var/lib/apt/lists/* + +# 复制依赖文件 +COPY requirements.txt . + +# 安装Python依赖 +RUN pip install --no-cache-dir -r requirements.txt + +# 复制应用代码 +COPY . . + +# 暴露端口 +EXPOSE 8000 + +# 启动命令 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e1431e0 --- /dev/null +++ b/README.md @@ -0,0 +1,214 @@ +# osu! API 模拟服务器 + +这是一个使用 FastAPI + MySQL + Redis 实现的 osu! API 模拟服务器,提供了完整的用户认证和数据管理功能。 + +## 功能特性 + +- **OAuth 2.0 认证**: 支持密码流和刷新令牌流 +- **用户数据管理**: 完整的用户信息、统计数据、成就等 +- **多游戏模式支持**: osu!, taiko, fruits, mania +- **数据库持久化**: MySQL 存储用户数据 +- **缓存支持**: Redis 缓存令牌和会话信息 +- **容器化部署**: Docker 和 Docker Compose 支持 + +## API 端点 + +### 认证端点 +- `POST /oauth/token` - OAuth 令牌获取/刷新 + +### 用户端点 +- `GET /api/v2/me/{ruleset}` - 获取当前用户信息 + +### 其他端点 +- `GET /` - 根端点 +- `GET /health` - 健康检查 + +## 快速开始 + +### 使用 Docker Compose (推荐) + +1. 克隆项目 +```bash +git clone +cd osu_lazer_api +``` + +2. 启动服务 +```bash +docker-compose up -d +``` + +3. 创建示例数据 +```bash +docker-compose exec api python create_sample_data.py +``` + +4. 测试 API +```bash +# 获取访问令牌 +curl -X POST http://localhost:8000/oauth/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=password&username=Googujiang&password=password123&client_id=5&client_secret=FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk&scope=*" + +# 使用令牌获取用户信息 +curl -X GET http://localhost:8000/api/v2/me/osu \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +### 本地开发 + +1. 安装依赖 +```bash +pip install -r requirements.txt +``` + +2. 配置环境变量 +```bash +# 复制服务器配置文件 +cp .env .env.local + +# 复制客户端配置文件(用于测试脚本) +cp .env.client .env.client.local +``` + +3. 启动 MySQL 和 Redis +```bash +# 使用 Docker +docker run -d --name mysql -e MYSQL_ROOT_PASSWORD=password -e MYSQL_DATABASE=osu_api -p 3306:3306 mysql:8.0 +docker run -d --name redis -p 6379:6379 redis:7-alpine +``` + +4. 创建示例数据 +```bash +python create_sample_data.py +``` + +5. 启动应用 +```bash +uvicorn main:app --reload +``` + +6. 测试 API +```bash +# 使用测试脚本(会自动加载 .env 文件) +python test_api.py + +# 或使用原始示例脚本 +python osu_api_example.py +``` + +## 项目结构 + +``` +osu_lazer_api/ +├── app/ +│ ├── __init__.py +│ ├── models.py # Pydantic 数据模型 +│ ├── database.py # SQLAlchemy 数据库模型 +│ ├── config.py # 配置设置 +│ ├── dependencies.py # 依赖注入 +│ ├── auth.py # 认证和令牌管理 +│ └── utils.py # 工具函数 +├── main.py # FastAPI 应用主文件 +├── create_sample_data.py # 示例数据创建脚本 +├── requirements.txt # Python 依赖 +├── .env # 环境变量配置 +├── docker-compose.yml # Docker Compose 配置 +├── Dockerfile # Docker 镜像配置 +└── README.md # 项目说明 +``` + +## 示例用户 + +创建示例数据后,您可以使用以下凭据进行测试: + +- **用户名**: `Googujiang` +- **密码**: `password123` +- **用户ID**: `15651670` + +## 环境变量配置 + +项目包含两个环境配置文件: + +### 服务器配置 (`.env`) +用于配置 FastAPI 服务器的运行参数: + +| 变量名 | 描述 | 默认值 | +|--------|------|--------| +| `DATABASE_URL` | MySQL 数据库连接字符串 | `mysql+pymysql://root:password@localhost:3306/osu_api` | +| `REDIS_URL` | Redis 连接字符串 | `redis://localhost:6379/0` | +| `SECRET_KEY` | JWT 签名密钥 | `your-secret-key-here` | +| `ACCESS_TOKEN_EXPIRE_MINUTES` | 访问令牌过期时间(分钟) | `1440` | +| `OSU_CLIENT_ID` | OAuth 客户端 ID | `5` | +| `OSU_CLIENT_SECRET` | OAuth 客户端密钥 | `FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk` | +| `HOST` | 服务器监听地址 | `0.0.0.0` | +| `PORT` | 服务器监听端口 | `8000` | +| `DEBUG` | 调试模式 | `True` | + +### 客户端配置 (`.env.client`) +用于配置客户端脚本的 API 连接参数: + +| 变量名 | 描述 | 默认值 | +|--------|------|--------| +| `OSU_CLIENT_ID` | OAuth 客户端 ID | `5` | +| `OSU_CLIENT_SECRET` | OAuth 客户端密钥 | `FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk` | +| `OSU_API_URL` | API 服务器地址 | `http://localhost:8000` | + +> **注意**: 在生产环境中,请务必更改默认的密钥和密码! + +## API 使用示例 + +### 获取访问令牌 + +```bash +curl -X POST http://localhost:8000/oauth/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=password&username=Googujiang&password=password123&client_id=5&client_secret=FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk&scope=*" +``` + +响应: +```json +{ + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...", + "token_type": "Bearer", + "expires_in": 86400, + "refresh_token": "abc123...", + "scope": "*" +} +``` + +### 获取用户信息 + +```bash +curl -X GET http://localhost:8000/api/v2/me/osu \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +### 刷新令牌 + +```bash +curl -X POST http://localhost:8000/oauth/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=refresh_token&refresh_token=YOUR_REFRESH_TOKEN&client_id=5&client_secret=FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk" +``` + +## 开发 + +### 添加新用户 + +您可以通过修改 `create_sample_data.py` 文件来添加更多示例用户,或者扩展 API 来支持用户注册功能。 + +### 扩展功能 + +- 添加更多 API 端点(排行榜、谱面信息等) +- 实现实时功能(WebSocket) +- 添加管理面板 +- 实现数据导入/导出功能 + +## 许可证 + +MIT License + +## 贡献 + +欢迎提交 Issue 和 Pull Request! diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..9fb9853 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# 初始化文件 diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..3dccc11 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,159 @@ +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext +from sqlalchemy.orm import Session +from app.database import User as DBUser, OAuthToken +from app.config import settings +import secrets +import string +import hashlib +import bcrypt + +# 密码哈希上下文 +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +# bcrypt 缓存(模拟应用状态缓存) +bcrypt_cache = {} + +def verify_password_legacy(plain_password: str, bcrypt_hash: str) -> bool: + """ + 验证密码 - 使用 osu! 的验证方式 + 1. 明文密码 -> MD5哈希 + 2. MD5哈希 -> bcrypt验证 + """ + # 1. 明文密码转 MD5 + pw_md5 = hashlib.md5(plain_password.encode()).hexdigest().encode() + + # 2. 检查缓存 + if bcrypt_hash in bcrypt_cache: + return bcrypt_cache[bcrypt_hash] == pw_md5 + + # 3. 如果缓存中没有,进行 bcrypt 验证 + try: + # 验证 MD5 哈希与 bcrypt 哈希 + is_valid = bcrypt.checkpw(pw_md5, bcrypt_hash.encode()) + + # 如果验证成功,将结果缓存 + if is_valid: + bcrypt_cache[bcrypt_hash] = pw_md5 + + return is_valid + except Exception as e: + print(f"Password verification error: {e}") + return False + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """验证密码(向后兼容)""" + # 首先尝试新的验证方式 + if verify_password_legacy(plain_password, hashed_password): + return True + + # 如果失败,尝试标准 bcrypt 验证 + return pwd_context.verify(plain_password, hashed_password) + +def get_password_hash(password: str) -> str: + """生成密码哈希 - 使用 osu! 的方式""" + # 1. 明文密码 -> MD5 + pw_md5 = hashlib.md5(password.encode()).hexdigest().encode() + # 2. MD5 -> bcrypt + pw_bcrypt = bcrypt.hashpw(pw_md5, bcrypt.gensalt()) + return pw_bcrypt.decode() + +def authenticate_user_legacy(db: Session, name: str, password: str) -> Optional[DBUser]: + """ + 验证用户身份 - 使用类似 from_login 的逻辑 + """ + # 1. 明文密码转 MD5 + pw_md5 = hashlib.md5(password.encode()).hexdigest() + + # 2. 根据用户名查找用户 + user = db.query(DBUser).filter(DBUser.name == name).first() + if not user: + return None + + # 3. 验证密码 + if not user.pw_bcrypt: + return None + + # 4. 检查缓存 + if user.pw_bcrypt in bcrypt_cache: + if bcrypt_cache[user.pw_bcrypt] == pw_md5.encode(): + return user + else: + return None + + # 5. 验证 bcrypt + try: + is_valid = bcrypt.checkpw(pw_md5.encode(), user.pw_bcrypt.encode()) + if is_valid: + # 缓存验证结果 + bcrypt_cache[user.pw_bcrypt] = pw_md5.encode() + return user + except Exception as e: + print(f"Authentication error for user {name}: {e}") + + return None + +def authenticate_user(db: Session, username: str, password: str) -> Optional[DBUser]: + """验证用户身份""" + return authenticate_user_legacy(db, username, password) + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """创建访问令牌""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + +def generate_refresh_token() -> str: + """生成刷新令牌""" + length = 64 + characters = string.ascii_letters + string.digits + return ''.join(secrets.choice(characters) for _ in range(length)) + +def verify_token(token: str) -> Optional[dict]: + """验证访问令牌""" + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + return payload + except JWTError: + return None + +def store_token(db: Session, user_id: int, access_token: str, refresh_token: str, expires_in: int) -> OAuthToken: + """存储令牌到数据库""" + expires_at = datetime.utcnow() + timedelta(seconds=expires_in) + + # 删除用户的旧令牌 + db.query(OAuthToken).filter(OAuthToken.user_id == user_id).delete() + + # 创建新令牌记录 + token_record = OAuthToken( + user_id=user_id, + access_token=access_token, + refresh_token=refresh_token, + expires_at=expires_at + ) + db.add(token_record) + db.commit() + db.refresh(token_record) + return token_record + +def get_token_by_access_token(db: Session, access_token: str) -> Optional[OAuthToken]: + """根据访问令牌获取令牌记录""" + return db.query(OAuthToken).filter( + OAuthToken.access_token == access_token, + OAuthToken.expires_at > datetime.utcnow() + ).first() + +def get_token_by_refresh_token(db: Session, refresh_token: str) -> Optional[OAuthToken]: + """根据刷新令牌获取令牌记录""" + return db.query(OAuthToken).filter( + OAuthToken.refresh_token == refresh_token, + OAuthToken.expires_at > datetime.utcnow() + ).first() diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..c1310e8 --- /dev/null +++ b/app/config.py @@ -0,0 +1,25 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +class Settings: + # 数据库设置 + DATABASE_URL: str = os.getenv("DATABASE_URL", "mysql+pymysql://root:password@localhost:3306/osu_api") + REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0") + + # JWT 设置 + SECRET_KEY: str = os.getenv("SECRET_KEY", "your-secret-key-here") + ALGORITHM: str = os.getenv("ALGORITHM", "HS256") + ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "1440")) + + # OAuth 设置 + OSU_CLIENT_ID: str = os.getenv("OSU_CLIENT_ID", "5") + OSU_CLIENT_SECRET: str = os.getenv("OSU_CLIENT_SECRET", "FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk") + + # 服务器设置 + HOST: str = os.getenv("HOST", "0.0.0.0") + PORT: int = int(os.getenv("PORT", "8000")) + DEBUG: bool = os.getenv("DEBUG", "True").lower() == "true" + +settings = Settings() diff --git a/app/database copy.py b/app/database copy.py new file mode 100644 index 0000000..7ba80bc --- /dev/null +++ b/app/database copy.py @@ -0,0 +1,403 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Float, Text, JSON, ForeignKey, Date, DECIMAL +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import relationship +from datetime import datetime + +Base = declarative_base() + + +class User(Base): + __tablename__ = "users" + + # 主键 + id = Column(Integer, primary_key=True, index=True) + + # 基本信息(匹配 migrations 中的结构) + name = Column(String(32), unique=True, index=True, nullable=False) # 用户名 + safe_name = Column(String(32), unique=True, index=True, nullable=False) # 安全用户名 + email = Column(String(254), unique=True, index=True, nullable=False) + priv = Column(Integer, default=1, nullable=False) # 权限 + pw_bcrypt = Column(String(60), nullable=False) # bcrypt 哈希密码 + country = Column(String(2), default='CN', nullable=False) # 国家代码 + + # 状态和时间 + silence_end = Column(Integer, default=0, nullable=False) + donor_end = Column(Integer, default=0, nullable=False) + creation_time = Column(Integer, default=0, nullable=False) # Unix 时间戳 + latest_activity = Column(Integer, default=0, nullable=False) # Unix 时间戳 + + # 游戏相关 + preferred_mode = Column(Integer, default=0, nullable=False) # 偏好游戏模式 + play_style = Column(Integer, default=0, nullable=False) # 游戏风格 + + # 扩展信息 + clan_id = Column(Integer, default=0, nullable=False) + clan_priv = Column(Integer, default=0, nullable=False) + custom_badge_name = Column(String(16)) + custom_badge_icon = Column(String(64)) + userpage_content = Column(String(2048)) + api_key = Column(String(36), unique=True) + + # 虚拟字段用于兼容性 + @property + def username(self): + return self.name + + @property + def country_code(self): + return self.country + + @property + def join_date(self): + return datetime.fromtimestamp(self.creation_time) if self.creation_time > 0 else datetime.utcnow() + + @property + def last_visit(self): + return datetime.fromtimestamp(self.latest_activity) if self.latest_activity > 0 else None + + # 关联关系 + lazer_profile = relationship("LazerUserProfile", back_populates="user", uselist=False, cascade="all, delete-orphan") + lazer_statistics = relationship("LazerUserStatistics", back_populates="user", cascade="all, delete-orphan") + lazer_achievements = relationship("LazerUserAchievement", back_populates="user", cascade="all, delete-orphan") + + +# ============================================ +# Lazer API 专用表模型 +# ============================================ + +class LazerUserProfile(Base): + __tablename__ = "lazer_user_profiles" + + user_id = Column(Integer, ForeignKey("users.id"), primary_key=True) + + # 基本状态字段 + is_active = Column(Boolean, default=True) + is_bot = Column(Boolean, default=False) + is_deleted = Column(Boolean, default=False) + is_online = Column(Boolean, default=True) + is_supporter = Column(Boolean, default=False) + is_restricted = Column(Boolean, default=False) + session_verified = Column(Boolean, default=False) + has_supported = Column(Boolean, default=False) + pm_friends_only = Column(Boolean, default=False) + + # 基本资料字段 + default_group = Column(String(50), default='default') + last_visit = Column(DateTime) + join_date = Column(DateTime) + profile_colour = Column(String(7)) + profile_hue = Column(Integer) + + # 社交媒体和个人资料字段 + avatar_url = Column(String(500)) + cover_url = Column(String(500)) + discord = Column(String(100)) + twitter = Column(String(100)) + website = Column(String(500)) + title = Column(String(100)) + title_url = Column(String(500)) + interests = Column(Text) + location = Column(String(100)) + occupation = Column(String(100)) + + # 游戏相关字段 + playmode = Column(String(10), default='osu') + support_level = Column(Integer, default=0) + max_blocks = Column(Integer, default=100) + max_friends = Column(Integer, default=500) + post_count = Column(Integer, default=0) + + # 页面内容 + page_html = Column(Text) + page_raw = Column(Text) + + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # 关联关系 + user = relationship("User", back_populates="lazer_profile") + + +class LazerUserCountry(Base): + __tablename__ = "lazer_user_countries" + + user_id = Column(Integer, ForeignKey("users.id"), primary_key=True) + code = Column(String(2), nullable=False) + name = Column(String(100), nullable=False) + + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class LazerUserKudosu(Base): + __tablename__ = "lazer_user_kudosu" + + user_id = Column(Integer, ForeignKey("users.id"), primary_key=True) + available = Column(Integer, default=0) + total = Column(Integer, default=0) + + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class LazerUserCounts(Base): + __tablename__ = "lazer_user_counts" + + user_id = Column(Integer, ForeignKey("users.id"), primary_key=True) + + # 统计计数字段 + beatmap_playcounts_count = Column(Integer, default=0) + comments_count = Column(Integer, default=0) + favourite_beatmapset_count = Column(Integer, default=0) + follower_count = Column(Integer, default=0) + graveyard_beatmapset_count = Column(Integer, default=0) + guest_beatmapset_count = Column(Integer, default=0) + loved_beatmapset_count = Column(Integer, default=0) + mapping_follower_count = Column(Integer, default=0) + nominated_beatmapset_count = Column(Integer, default=0) + pending_beatmapset_count = Column(Integer, default=0) + ranked_beatmapset_count = Column(Integer, default=0) + ranked_and_approved_beatmapset_count = Column(Integer, default=0) + unranked_beatmapset_count = Column(Integer, default=0) + scores_best_count = Column(Integer, default=0) + scores_first_count = Column(Integer, default=0) + scores_pinned_count = Column(Integer, default=0) + scores_recent_count = Column(Integer, default=0) + + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class LazerUserStatistics(Base): + __tablename__ = "lazer_user_statistics" + + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + mode = Column(String(10), nullable=False, default='osu') + + # 基本命中统计 + count_100 = Column(Integer, default=0) + count_300 = Column(Integer, default=0) + count_50 = Column(Integer, default=0) + count_miss = Column(Integer, default=0) + + # 等级信息 + level_current = Column(Integer, default=1) + level_progress = Column(Integer, default=0) + + # 排名信息 + global_rank = Column(Integer) + global_rank_exp = Column(Integer) + country_rank = Column(Integer) + + # PP 和分数 + pp = Column(DECIMAL(10, 2), default=0.00) + pp_exp = Column(DECIMAL(10, 2), default=0.00) + ranked_score = Column(Integer, default=0) + hit_accuracy = Column(DECIMAL(5, 2), default=0.00) + total_score = Column(Integer, default=0) + total_hits = Column(Integer, default=0) + maximum_combo = Column(Integer, default=0) +# ============================================ +# 旧的兼容性表模型(保留以便向后兼容) +# ============================================ + +class LegacyUserStatistics(Base): + __tablename__ = "user_statistics" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + mode = Column(String(10), nullable=False) # osu, taiko, fruits, mania + + # 基本统计 + count_100 = Column(Integer, default=0) + count_300 = Column(Integer, default=0) + count_50 = Column(Integer, default=0) + count_miss = Column(Integer, default=0) + + # 等级信息 + level_current = Column(Integer, default=1) + level_progress = Column(Integer, default=0) + + # 排名信息 + global_rank = Column(Integer) + global_rank_exp = Column(Integer) + country_rank = Column(Integer) + + # PP 和分数 + pp = Column(Float, default=0.0) + pp_exp = Column(Float, default=0.0) + ranked_score = Column(Integer, default=0) + hit_accuracy = Column(Float, default=0.0) + total_score = Column(Integer, default=0) + total_hits = Column(Integer, default=0) + maximum_combo = Column(Integer, default=0) + + # 游戏统计 + play_count = Column(Integer, default=0) + play_time = Column(Integer, default=0) + replays_watched_by_others = Column(Integer, default=0) + is_ranked = Column(Boolean, default=False) + + # 成绩等级计数 + grade_ss = Column(Integer, default=0) + grade_ssh = Column(Integer, default=0) + grade_s = Column(Integer, default=0) + grade_sh = Column(Integer, default=0) + grade_a = Column(Integer, default=0) + + +class OAuthToken(Base): + __tablename__ = "oauth_tokens" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + access_token = Column(String(255), nullable=False, index=True) + refresh_token = Column(String(255), nullable=False, index=True) + expires_at = Column(DateTime, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + previous_usernames = Column(JSON, default=list) + replays_watched_counts = Column(JSON, default=list) + + # 关联关系 + statistics = relationship("UserStatistics", back_populates="user", cascade="all, delete-orphan") + achievements = relationship("UserAchievement", back_populates="user", cascade="all, delete-orphan") + team_membership = relationship("TeamMember", back_populates="user", cascade="all, delete-orphan") + daily_challenge_stats = relationship("DailyChallengeStats", back_populates="user", uselist=False, cascade="all, delete-orphan") + rank_history = relationship("RankHistory", back_populates="user", cascade="all, delete-orphan") + + +class UserStatistics(Base): + __tablename__ = "user_statistics" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + mode = Column(String(10), nullable=False) # osu, taiko, fruits, mania + + # 基本统计 + count_100 = Column(Integer, default=0) + count_300 = Column(Integer, default=0) + count_50 = Column(Integer, default=0) + count_miss = Column(Integer, default=0) + + # 等级信息 + level_current = Column(Integer, default=1) + level_progress = Column(Integer, default=0) + + # 排名信息 + global_rank = Column(Integer) + global_rank_exp = Column(Integer) + country_rank = Column(Integer) + + # PP 和分数 + pp = Column(Float, default=0.0) + pp_exp = Column(Float, default=0.0) + ranked_score = Column(Integer, default=0) + hit_accuracy = Column(Float, default=0.0) + total_score = Column(Integer, default=0) + total_hits = Column(Integer, default=0) + maximum_combo = Column(Integer, default=0) + + # 游戏统计 + play_count = Column(Integer, default=0) + play_time = Column(Integer, default=0) # 秒 + replays_watched_by_others = Column(Integer, default=0) + is_ranked = Column(Boolean, default=False) + + # 成绩等级计数 + grade_ss = Column(Integer, default=0) + grade_ssh = Column(Integer, default=0) + grade_s = Column(Integer, default=0) + grade_sh = Column(Integer, default=0) + grade_a = Column(Integer, default=0) + + # 最高排名记录 + rank_highest = Column(Integer) + rank_highest_updated_at = Column(DateTime) + + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # 关联关系 + user = relationship("User", back_populates="statistics") + + +class UserAchievement(Base): + __tablename__ = "user_achievements" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + achievement_id = Column(Integer, nullable=False) + achieved_at = Column(DateTime, default=datetime.utcnow) + + user = relationship("User", back_populates="achievements") + + +class Team(Base): + __tablename__ = "teams" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False) + short_name = Column(String(10), nullable=False) + flag_url = Column(String(500)) + created_at = Column(DateTime, default=datetime.utcnow) + + members = relationship("TeamMember", back_populates="team", cascade="all, delete-orphan") + + +class TeamMember(Base): + __tablename__ = "team_members" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + team_id = Column(Integer, ForeignKey("teams.id"), nullable=False) + joined_at = Column(DateTime, default=datetime.utcnow) + + user = relationship("User", back_populates="team_membership") + team = relationship("Team", back_populates="members") + + +class DailyChallengeStats(Base): + __tablename__ = "daily_challenge_stats" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True) + + daily_streak_best = Column(Integer, default=0) + daily_streak_current = Column(Integer, default=0) + last_update = Column(DateTime) + last_weekly_streak = Column(DateTime) + playcount = Column(Integer, default=0) + top_10p_placements = Column(Integer, default=0) + top_50p_placements = Column(Integer, default=0) + weekly_streak_best = Column(Integer, default=0) + weekly_streak_current = Column(Integer, default=0) + + user = relationship("User", back_populates="daily_challenge_stats") + + +class RankHistory(Base): + __tablename__ = "rank_history" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + mode = Column(String(10), nullable=False) + rank_data = Column(JSON, nullable=False) # Array of ranks + date_recorded = Column(DateTime, default=datetime.utcnow) + + user = relationship("User", back_populates="rank_history") + + +class OAuthToken(Base): + __tablename__ = "oauth_tokens" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + access_token = Column(String(500), unique=True, nullable=False) + refresh_token = Column(String(500), unique=True, nullable=False) + token_type = Column(String(20), default="Bearer") + scope = Column(String(100), default="*") + expires_at = Column(DateTime, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + + user = relationship("User") diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..ed2ba61 --- /dev/null +++ b/app/database.py @@ -0,0 +1,426 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Float, Text, JSON, ForeignKey, Date, DECIMAL +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import relationship +from datetime import datetime +from dataclasses import dataclass + +Base = declarative_base() + + +class User(Base): + __tablename__ = "users" + + # 主键 + id = Column(Integer, primary_key=True, index=True) + + # 基本信息(匹配 migrations 中的结构) + name = Column(String(32), unique=True, index=True, nullable=False) # 用户名 + safe_name = Column(String(32), unique=True, index=True, nullable=False) # 安全用户名 + email = Column(String(254), unique=True, index=True, nullable=False) + priv = Column(Integer, default=1, nullable=False) # 权限 + pw_bcrypt = Column(String(60), nullable=False) # bcrypt 哈希密码 + country = Column(String(2), default='CN', nullable=False) # 国家代码 + + # 状态和时间 + silence_end = Column(Integer, default=0, nullable=False) + donor_end = Column(Integer, default=0, nullable=False) + creation_time = Column(Integer, default=0, nullable=False) # Unix 时间戳 + latest_activity = Column(Integer, default=0, nullable=False) # Unix 时间戳 + + # 游戏相关 + preferred_mode = Column(Integer, default=0, nullable=False) # 偏好游戏模式 + play_style = Column(Integer, default=0, nullable=False) # 游戏风格 + + # 扩展信息 + clan_id = Column(Integer, default=0, nullable=False) + clan_priv = Column(Integer, default=0, nullable=False) + custom_badge_name = Column(String(16)) + custom_badge_icon = Column(String(64)) + userpage_content = Column(String(2048)) + api_key = Column(String(36), unique=True) + + # 虚拟字段用于兼容性 + @property + def username(self): + return self.name + + @property + def country_code(self): + return self.country + + @property + def join_date(self): + creation_time = getattr(self, 'creation_time', 0) + return datetime.fromtimestamp(creation_time) if creation_time > 0 else datetime.utcnow() + @property + def last_visit(self): + latest_activity = getattr(self, 'latest_activity', 0) + return datetime.fromtimestamp(latest_activity) if latest_activity > 0 else None + + # 关联关系 + lazer_profile = relationship("LazerUserProfile", back_populates="user", uselist=False, cascade="all, delete-orphan") + lazer_statistics = relationship("LazerUserStatistics", back_populates="user", cascade="all, delete-orphan") + lazer_achievements = relationship("LazerUserAchievement", back_populates="user", cascade="all, delete-orphan") + statistics = relationship("LegacyUserStatistics", back_populates="user", cascade="all, delete-orphan") + achievements = relationship("LazerUserAchievement", back_populates="user", cascade="all, delete-orphan") + team_membership = relationship("TeamMember", back_populates="user", cascade="all, delete-orphan") + daily_challenge_stats = relationship("DailyChallengeStats", back_populates="user", uselist=False, cascade="all, delete-orphan") + rank_history = relationship("RankHistory", back_populates="user", cascade="all, delete-orphan") + avatar = relationship("UserAvatar", back_populates="user", primaryjoin="and_(User.id==UserAvatar.user_id, UserAvatar.is_active==True)", uselist=False) + + +# ============================================ +# Lazer API 专用表模型 +# ============================================ + +class LazerUserProfile(Base): + __tablename__ = "lazer_user_profiles" + + user_id = Column(Integer, ForeignKey("users.id"), primary_key=True) + + # 基本状态字段 + is_active = Column(Boolean, default=True) + is_bot = Column(Boolean, default=False) + is_deleted = Column(Boolean, default=False) + is_online = Column(Boolean, default=True) + is_supporter = Column(Boolean, default=False) + is_restricted = Column(Boolean, default=False) + session_verified = Column(Boolean, default=False) + has_supported = Column(Boolean, default=False) + pm_friends_only = Column(Boolean, default=False) + + # 基本资料字段 + default_group = Column(String(50), default='default') + last_visit = Column(DateTime) + join_date = Column(DateTime) + profile_colour = Column(String(7)) + profile_hue = Column(Integer) + + # 社交媒体和个人资料字段 + avatar_url = Column(String(500)) + cover_url = Column(String(500)) + discord = Column(String(100)) + twitter = Column(String(100)) + website = Column(String(500)) + title = Column(String(100)) + title_url = Column(String(500)) + interests = Column(Text) + location = Column(String(100)) + + occupation = None # 职业字段,默认为 None + + # 游戏相关字段 + playmode = Column(String(10), default='osu') + support_level = Column(Integer, default=0) + max_blocks = Column(Integer, default=100) + max_friends = Column(Integer, default=500) + post_count = Column(Integer, default=0) + + # 页面内容 + page_html = Column(Text) + page_raw = Column(Text) + + #created_at = Column(DateTime, default=datetime.utcnow) + #updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # 关联关系 + user = relationship("User", back_populates="lazer_profile") + + +class LazerUserCountry(Base): + __tablename__ = "lazer_user_countries" + + user_id = Column(Integer, ForeignKey("users.id"), primary_key=True) + code = Column(String(2), nullable=False) + name = Column(String(100), nullable=False) + + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class LazerUserKudosu(Base): + __tablename__ = "lazer_user_kudosu" + + user_id = Column(Integer, ForeignKey("users.id"), primary_key=True) + available = Column(Integer, default=0) + total = Column(Integer, default=0) + + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class LazerUserCounts(Base): + __tablename__ = "lazer_user_counts" + + user_id = Column(Integer, ForeignKey("users.id"), primary_key=True) + + # 统计计数字段 + beatmap_playcounts_count = Column(Integer, default=0) + comments_count = Column(Integer, default=0) + favourite_beatmapset_count = Column(Integer, default=0) + follower_count = Column(Integer, default=0) + graveyard_beatmapset_count = Column(Integer, default=0) + guest_beatmapset_count = Column(Integer, default=0) + loved_beatmapset_count = Column(Integer, default=0) + mapping_follower_count = Column(Integer, default=0) + nominated_beatmapset_count = Column(Integer, default=0) + pending_beatmapset_count = Column(Integer, default=0) + ranked_beatmapset_count = Column(Integer, default=0) + ranked_and_approved_beatmapset_count = Column(Integer, default=0) + unranked_beatmapset_count = Column(Integer, default=0) + scores_best_count = Column(Integer, default=0) + scores_first_count = Column(Integer, default=0) + scores_pinned_count = Column(Integer, default=0) + scores_recent_count = Column(Integer, default=0) + + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class LazerUserStatistics(Base): + __tablename__ = "lazer_user_statistics" + + user_id = Column(Integer, ForeignKey("users.id"), primary_key=True) + mode = Column(String(10), nullable=False, default='osu', primary_key=True) + + # 基本命中统计 + count_100 = Column(Integer, default=0) + count_300 = Column(Integer, default=0) + count_50 = Column(Integer, default=0) + count_miss = Column(Integer, default=0) + + # 等级信息 + level_current = Column(Integer, default=1) + level_progress = Column(Integer, default=0) + + # 排名信息 + global_rank = Column(Integer) + global_rank_exp = Column(Integer) + country_rank = Column(Integer) + + # PP 和分数 + pp = Column(DECIMAL(10, 2), default=0.00) + pp_exp = Column(DECIMAL(10, 2), default=0.00) + ranked_score = Column(Integer, default=0) + hit_accuracy = Column(DECIMAL(5, 2), default=0.00) + total_score = Column(Integer, default=0) + total_hits = Column(Integer, default=0) + maximum_combo = Column(Integer, default=0) + + # 游戏统计 + play_count = Column(Integer, default=0) + play_time = Column(Integer, default=0) # 秒 + replays_watched_by_others = Column(Integer, default=0) + is_ranked = Column(Boolean, default=False) + + # 成绩等级计数 + grade_ss = Column(Integer, default=0) + grade_ssh = Column(Integer, default=0) + grade_s = Column(Integer, default=0) + grade_sh = Column(Integer, default=0) + grade_a = Column(Integer, default=0) + + # 最高排名记录 + rank_highest = Column(Integer) + rank_highest_updated_at = Column(DateTime) + + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # 关联关系 + user = relationship("User", back_populates="lazer_statistics") + + +class LazerUserAchievement(Base): + __tablename__ = "lazer_user_achievements" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + achievement_id = Column(Integer, nullable=False) + achieved_at = Column(DateTime, default=datetime.utcnow) + + #created_at = Column(DateTime, default=datetime.utcnow) + #updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + user = relationship("User", back_populates="lazer_achievements") + +# ============================================ +# 旧的兼容性表模型(保留以便向后兼容) +# ============================================ + +class LegacyUserStatistics(Base): + __tablename__ = "user_statistics" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + mode = Column(String(10), nullable=False) # osu, taiko, fruits, mania + + # 基本统计 + count_100 = Column(Integer, default=0) + count_300 = Column(Integer, default=0) + count_50 = Column(Integer, default=0) + count_miss = Column(Integer, default=0) + + # 等级信息 + level_current = Column(Integer, default=1) + level_progress = Column(Integer, default=0) + + # 排名信息 + global_rank = Column(Integer) + global_rank_exp = Column(Integer) + country_rank = Column(Integer) + + # PP 和分数 + pp = Column(Float, default=0.0) + pp_exp = Column(Float, default=0.0) + ranked_score = Column(Integer, default=0) + hit_accuracy = Column(Float, default=0.0) + total_score = Column(Integer, default=0) + total_hits = Column(Integer, default=0) + maximum_combo = Column(Integer, default=0) + + # 游戏统计 + play_count = Column(Integer, default=0) + play_time = Column(Integer, default=0) + replays_watched_by_others = Column(Integer, default=0) + is_ranked = Column(Boolean, default=False) + + # 成绩等级计数 + grade_ss = Column(Integer, default=0) + grade_ssh = Column(Integer, default=0) + grade_s = Column(Integer, default=0) + grade_sh = Column(Integer, default=0) + grade_a = Column(Integer, default=0) + + # 最高排名记录 + rank_highest = Column(Integer) + rank_highest_updated_at = Column(DateTime) + + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # 关联关系 + user = relationship("User", back_populates="statistics") + + +class LegacyOAuthToken(Base): + __tablename__ = "legacy_oauth_tokens" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + access_token = Column(String(255), nullable=False, index=True) + refresh_token = Column(String(255), nullable=False, index=True) + expires_at = Column(DateTime, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + previous_usernames = Column(JSON, default=list) + replays_watched_counts = Column(JSON, default=list) + + # 用户关系 + user = relationship("User") + + +# class UserAchievement(Base): +# __tablename__ = "lazer_user_achievements" + +# id = Column(Integer, primary_key=True, index=True) +# user_id = Column(Integer, ForeignKey("users.id"), nullable=False) +# achievement_id = Column(Integer, nullable=False) +# achieved_at = Column(DateTime, default=datetime.utcnow) + +# user = relationship("User", back_populates="achievements") + + +# 类型转换用的 UserAchievement(不是 SQLAlchemy 模型) +@dataclass +class UserAchievement: + achieved_at: datetime + achievement_id: int + + +class Team(Base): + __tablename__ = "teams" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False) + short_name = Column(String(10), nullable=False) + flag_url = Column(String(500)) + created_at = Column(DateTime, default=datetime.utcnow) + + members = relationship("TeamMember", back_populates="team", cascade="all, delete-orphan") + + +class TeamMember(Base): + __tablename__ = "team_members" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + team_id = Column(Integer, ForeignKey("teams.id"), nullable=False) + joined_at = Column(DateTime, default=datetime.utcnow) + + user = relationship("User", back_populates="team_membership") + team = relationship("Team", back_populates="members") + + +class DailyChallengeStats(Base): + __tablename__ = "daily_challenge_stats" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True) + + daily_streak_best = Column(Integer, default=0) + daily_streak_current = Column(Integer, default=0) + last_update = Column(DateTime) + last_weekly_streak = Column(DateTime) + playcount = Column(Integer, default=0) + top_10p_placements = Column(Integer, default=0) + top_50p_placements = Column(Integer, default=0) + weekly_streak_best = Column(Integer, default=0) + weekly_streak_current = Column(Integer, default=0) + + user = relationship("User", back_populates="daily_challenge_stats") + + +class RankHistory(Base): + __tablename__ = "rank_history" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + mode = Column(String(10), nullable=False) + rank_data = Column(JSON, nullable=False) # Array of ranks + date_recorded = Column(DateTime, default=datetime.utcnow) + + user = relationship("User", back_populates="rank_history") + + +class OAuthToken(Base): + __tablename__ = "oauth_tokens" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + access_token = Column(String(500), unique=True, nullable=False) + refresh_token = Column(String(500), unique=True, nullable=False) + token_type = Column(String(20), default="Bearer") + scope = Column(String(100), default="*") + expires_at = Column(DateTime, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + + user = relationship("User") + + +class UserAvatar(Base): + __tablename__ = "user_avatars" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + filename = Column(String(255), nullable=False) + original_filename = Column(String(255), nullable=False) + file_size = Column(Integer, nullable=False) + mime_type = Column(String(100), nullable=False) + is_active = Column(Boolean, default=True) + created_at = Column(Integer, default=lambda: int(datetime.now().timestamp())) + updated_at = Column(Integer, default=lambda: int(datetime.now().timestamp())) + r2_original_url = Column(String(500)) + r2_game_url = Column(String(500)) + + user = relationship("User", back_populates="avatar") diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000..d3c6cb8 --- /dev/null +++ b/app/dependencies.py @@ -0,0 +1,29 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +try: + import redis +except ImportError: + redis = None +from app.config import settings + +# 数据库引擎 +engine = create_engine(settings.DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# Redis 连接 +if redis: + redis_client = redis.from_url(settings.REDIS_URL, decode_responses=True) +else: + redis_client = None + +# 数据库依赖 +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + +# Redis 依赖 +def get_redis(): + return redis_client diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..620e2a3 --- /dev/null +++ b/app/models.py @@ -0,0 +1,226 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime +from enum import Enum + + +class GameMode(str, Enum): + OSU = "osu" + TAIKO = "taiko" + FRUITS = "fruits" + MANIA = "mania" + + +class PlayStyle(str, Enum): + MOUSE = "mouse" + KEYBOARD = "keyboard" + TABLET = "tablet" + TOUCH = "touch" + + +class Country(BaseModel): + code: str + name: str + + +class Cover(BaseModel): + custom_url: Optional[str] = None + url: str + id: Optional[int] = None + + +class Level(BaseModel): + current: int + progress: int + + +class GradeCounts(BaseModel): + ss: int = 0 + ssh: int = 0 + s: int = 0 + sh: int = 0 + a: int = 0 + + +class Statistics(BaseModel): + count_100: int = 0 + count_300: int = 0 + count_50: int = 0 + count_miss: int = 0 + level: Level + global_rank: Optional[int] = None + global_rank_exp: Optional[int] = None + pp: float = 0.0 + pp_exp: float = 0.0 + ranked_score: int = 0 + hit_accuracy: float = 0.0 + play_count: int = 0 + play_time: int = 0 + total_score: int = 0 + total_hits: int = 0 + maximum_combo: int = 0 + replays_watched_by_others: int = 0 + is_ranked: bool = False + grade_counts: GradeCounts + country_rank: Optional[int] = None + rank: Optional[dict] = None + + +class Kudosu(BaseModel): + available: int = 0 + total: int = 0 + + +class MonthlyPlaycount(BaseModel): + start_date: str + count: int + + +class UserAchievement(BaseModel): + achieved_at: datetime + achievement_id: int + + +class RankHighest(BaseModel): + rank: int + updated_at: datetime + + +class RankHistory(BaseModel): + mode: str + data: List[int] + + +class DailyChallengeStats(BaseModel): + daily_streak_best: int = 0 + daily_streak_current: int = 0 + last_update: Optional[datetime] = None + last_weekly_streak: Optional[datetime] = None + playcount: int = 0 + top_10p_placements: int = 0 + top_50p_placements: int = 0 + user_id: int + weekly_streak_best: int = 0 + weekly_streak_current: int = 0 + + +class Team(BaseModel): + flag_url: str + id: int + name: str + short_name: str + + +class Page(BaseModel): + html: str = "" + raw: str = "" + + +class User(BaseModel): + # 基本信息 + id: int + username: str + avatar_url: str + country_code: str + default_group: str = "default" + is_active: bool = True + is_bot: bool = False + is_deleted: bool = False + is_online: bool = True + is_supporter: bool = False + is_restricted: bool = False + last_visit: Optional[datetime] = None + pm_friends_only: bool = False + profile_colour: Optional[str] = None + + # 个人资料 + cover_url: Optional[str] = None + discord: Optional[str] = None + has_supported: bool = False + interests: Optional[str] = None + join_date: datetime + location: Optional[str] = None + max_blocks: int = 100 + max_friends: int = 500 + occupation: Optional[str] = None + playmode: GameMode = GameMode.OSU + playstyle: List[PlayStyle] = [] + post_count: int = 0 + profile_hue: Optional[int] = None + profile_order: List[str] = ["me", "recent_activity", "top_ranks", "medals", "historical", "beatmaps", "kudosu"] + title: Optional[str] = None + title_url: Optional[str] = None + twitter: Optional[str] = None + website: Optional[str] = None + session_verified: bool = False + support_level: int = 0 + + # 关联对象 + country: Country + cover: Cover + kudosu: Kudosu + statistics: Statistics + statistics_rulesets: dict[str, Statistics] + + # 计数信息 + beatmap_playcounts_count: int = 0 + comments_count: int = 0 + favourite_beatmapset_count: int = 0 + follower_count: int = 0 + graveyard_beatmapset_count: int = 0 + guest_beatmapset_count: int = 0 + loved_beatmapset_count: int = 0 + mapping_follower_count: int = 0 + nominated_beatmapset_count: int = 0 + pending_beatmapset_count: int = 0 + ranked_beatmapset_count: int = 0 + ranked_and_approved_beatmapset_count: int = 0 + unranked_beatmapset_count: int = 0 + scores_best_count: int = 0 + scores_first_count: int = 0 + scores_pinned_count: int = 0 + scores_recent_count: int = 0 + + # 历史数据 + account_history: List[dict] = [] + active_tournament_banner: Optional[dict] = None + active_tournament_banners: List[dict] = [] + badges: List[dict] = [] + current_season_stats: Optional[dict] = None + daily_challenge_user_stats: Optional[DailyChallengeStats] = None + groups: List[dict] = [] + monthly_playcounts: List[MonthlyPlaycount] = [] + page: Page = Page() + previous_usernames: List[str] = [] + rank_highest: Optional[RankHighest] = None + rank_history: Optional[RankHistory] = None + rankHistory: Optional[RankHistory] = None # 兼容性别名 + replays_watched_counts: List[dict] = [] + team: Optional[Team] = None + user_achievements: List[UserAchievement] = [] + + +# OAuth 相关模型 +class TokenRequest(BaseModel): + grant_type: str + username: Optional[str] = None + password: Optional[str] = None + refresh_token: Optional[str] = None + client_id: str + client_secret: str + scope: str = "*" + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "Bearer" + expires_in: int + refresh_token: str + scope: str = "*" + + +class UserCreate(BaseModel): + username: str + password: str + email: str + country_code: str = "CN" diff --git a/app/utils.py b/app/utils.py new file mode 100644 index 0000000..c762bff --- /dev/null +++ b/app/utils.py @@ -0,0 +1,447 @@ +from typing import Dict, List, Optional +from datetime import datetime +from app.models import * +from app.database import User as DBUser, LazerUserStatistics, LazerUserProfile, LazerUserCountry, LazerUserKudosu, LazerUserCounts, LazerUserAchievement +from sqlalchemy.orm import Session + + +def convert_db_user_to_api_user(db_user: DBUser, ruleset: str = "osu", db_session: Session = None) -> User: + """将数据库用户模型转换为API用户模型(使用 Lazer 表)""" + + # 从db_user获取基本字段值 + user_id = getattr(db_user, 'id') + user_name = getattr(db_user, 'name') + user_country = getattr(db_user, 'country') + user_country_code = user_country # 在User模型中,country字段就是country_code + + # 获取 Lazer 用户资料 + profile = db_user.lazer_profile + if not profile: + # 如果没有 lazer 资料,使用默认值 + profile = create_default_profile(db_user) + + # 获取指定模式的统计信息 + user_stats = None + for stat in db_user.lazer_statistics: + if stat.mode == ruleset: + user_stats = stat + break + + if not user_stats: + # 如果没有找到指定模式的统计,创建默认统计 + user_stats = create_default_lazer_statistics(ruleset) + + # 获取国家信息 + country = Country( + code=db_user.country_code, + name=get_country_name(db_user.country_code) + ) + + # 获取 Kudosu 信息 + kudosu = Kudosu(available=0, total=0) + + # 获取计数信息 + counts = create_default_counts() + + # 转换统计信息 + statistics = Statistics( + count_100=user_stats.count_100, + count_300=user_stats.count_300, + count_50=user_stats.count_50, + count_miss=user_stats.count_miss, + level=Level( + current=user_stats.level_current, + progress=user_stats.level_progress + ), + global_rank=user_stats.global_rank, + global_rank_exp=user_stats.global_rank_exp, + pp=float(user_stats.pp) if user_stats.pp else 0.0, + pp_exp=float(user_stats.pp_exp) if user_stats.pp_exp else 0.0, + ranked_score=user_stats.ranked_score, + hit_accuracy=float(user_stats.hit_accuracy) if user_stats.hit_accuracy else 0.0, + play_count=user_stats.play_count, + play_time=user_stats.play_time, + total_score=user_stats.total_score, + total_hits=user_stats.total_hits, + maximum_combo=user_stats.maximum_combo, + replays_watched_by_others=user_stats.replays_watched_by_others, + is_ranked=user_stats.is_ranked, + grade_counts=GradeCounts( + ss=user_stats.grade_ss, + ssh=user_stats.grade_ssh, + s=user_stats.grade_s, + sh=user_stats.grade_sh, + a=user_stats.grade_a + ), + country_rank=user_stats.country_rank, + rank={"country": user_stats.country_rank} if user_stats.country_rank else None + ) + + # 转换所有模式的统计信息 + statistics_rulesets = {} + for stat in db_user.statistics: + statistics_rulesets[stat.mode] = Statistics( + count_100=stat.count_100, + count_300=stat.count_300, + count_50=stat.count_50, + count_miss=stat.count_miss, + level=Level(current=stat.level_current, progress=stat.level_progress), + global_rank=stat.global_rank, + global_rank_exp=stat.global_rank_exp, + pp=stat.pp, + pp_exp=stat.pp_exp, + ranked_score=stat.ranked_score, + hit_accuracy=stat.hit_accuracy, + play_count=stat.play_count, + play_time=stat.play_time, + total_score=stat.total_score, + total_hits=stat.total_hits, + maximum_combo=stat.maximum_combo, + replays_watched_by_others=stat.replays_watched_by_others, + is_ranked=stat.is_ranked, + grade_counts=GradeCounts( + ss=stat.grade_ss, + ssh=stat.grade_ssh, + s=stat.grade_s, + sh=stat.grade_sh, + a=stat.grade_a + ) + ) + + # 转换国家信息 + country = Country( + code=user_country_code, + name=get_country_name(user_country_code) + ) + + # 转换封面信息 + cover_url = profile.cover_url if profile and profile.cover_url else "https://assets.ppy.sh/user-profile-covers/default.jpeg" + cover = Cover( + custom_url=profile.cover_url if profile else None, + url=str(cover_url), + id=None + ) + + # 转换 Kudosu 信息 + kudosu = Kudosu(available=0, total=0) + + # 转换成就信息 + user_achievements = [] + if db_user.lazer_achievements: + for achievement in db_user.lazer_achievements: + user_achievements.append(UserAchievement( + achieved_at=achievement.achieved_at, + achievement_id=achievement.achievement_id + )) + + # 转换排名历史 + rank_history = None + rank_history_data = None + for rh in db_user.rank_history: + if rh.mode == ruleset: + rank_history_data = rh.rank_data + break + + if rank_history_data: + rank_history = RankHistory(mode=ruleset, data=rank_history_data) + + # 转换每日挑战统计 + daily_challenge_stats = None + if db_user.daily_challenge_stats: + dcs = db_user.daily_challenge_stats + daily_challenge_stats = DailyChallengeStats( + daily_streak_best=dcs.daily_streak_best, + daily_streak_current=dcs.daily_streak_current, + last_update=dcs.last_update, + last_weekly_streak=dcs.last_weekly_streak, + playcount=dcs.playcount, + top_10p_placements=dcs.top_10p_placements, + top_50p_placements=dcs.top_50p_placements, + user_id=dcs.user_id, + weekly_streak_best=dcs.weekly_streak_best, + weekly_streak_current=dcs.weekly_streak_current + ) + + # 转换最高排名 + rank_highest = None + if user_stats.rank_highest: + rank_highest = RankHighest( + rank=user_stats.rank_highest, + updated_at=user_stats.rank_highest_updated_at or datetime.utcnow() + ) + + # 转换团队信息 + team = None + if db_user.team_membership: + team_member = db_user.team_membership[0] # 假设用户只属于一个团队 + team = Team( + flag_url=team_member.team.flag_url or "", + id=team_member.team.id, + name=team_member.team.name, + short_name=team_member.team.short_name + ) + + # 创建用户对象 + # 从db_user获取基本字段值 + user_id = getattr(db_user, 'id') + user_name = getattr(db_user, 'name') + user_country = getattr(db_user, 'country') + + # 获取用户头像URL + avatar_url = None + + # 首先检查 profile 中的 avatar_url + if profile and hasattr(profile, 'avatar_url') and profile.avatar_url: + avatar_url = str(profile.avatar_url) + + # 然后检查是否有关联的头像记录 + if avatar_url is None and hasattr(db_user, 'avatar') and db_user.avatar is not None: + if db_user.avatar.r2_game_url: + # 优先使用游戏用的头像URL + avatar_url = str(db_user.avatar.r2_game_url) + elif db_user.avatar.r2_original_url: + # 其次使用原始头像URL + avatar_url = str(db_user.avatar.r2_original_url) + + # 如果还是没有找到,通过查询获取 + if db_session and avatar_url is None: + try: + # 导入UserAvatar模型 + from app.database import UserAvatar + + # 尝试查找用户的头像记录 + avatar_record = db_session.query(UserAvatar).filter_by(user_id=user_id, is_active=True).first() + if avatar_record is not None: + if avatar_record.r2_game_url is not None: + # 优先使用游戏用的头像URL + avatar_url = str(avatar_record.r2_game_url) + elif avatar_record.r2_original_url is not None: + # 其次使用原始头像URL + avatar_url = str(avatar_record.r2_original_url) + except Exception as e: + print(f"获取用户头像时出错: {e}") + print(f"最终头像URL: {avatar_url}") + # 如果仍然没有找到头像URL,则使用默认URL + if avatar_url is None: + avatar_url = f"https://a.gu-osu.gmoe.cc/api/users/avatar/1" + + user = User( + id=user_id, + username=user_name, + avatar_url=avatar_url, # 使用我们上面获取的头像URL + country_code=user_country, + default_group=profile.default_group if profile else "default", + is_active=profile.is_active if profile else True, + is_bot=profile.is_bot if profile else False, + is_deleted=profile.is_deleted if profile else False, + is_online=profile.is_online if profile else True, + is_supporter=profile.is_supporter if profile else False, + is_restricted=profile.is_restricted if profile else False, + last_visit=db_user.last_visit, + pm_friends_only=profile.pm_friends_only if profile else False, + profile_colour=profile.profile_colour if profile else None, + cover_url=cover_url, + discord=profile.discord if profile else None, + has_supported=profile.has_supported if profile else False, + interests=profile.interests if profile else None, + join_date=db_user.join_date, + location=profile.location if profile else None, + max_blocks=profile.max_blocks if profile else 100, + max_friends=profile.max_friends if profile else 500, + + occupation=None, # 职业字段,默认为 None #待修改 + + #playmode=GameMode(db_user.playmode), + playmode=GameMode("osu"), #待修改 + + playstyle=[PlayStyle.MOUSE, PlayStyle.KEYBOARD, PlayStyle.TABLET], #待修改 + + post_count=0, + profile_hue=None, + profile_order= ['me', 'recent_activity', 'top_ranks', 'medals', 'historical', 'beatmaps', 'kudosu'], + title=None, + title_url=None, + twitter=None, + website='https://gmoe.cc', + session_verified=True, + support_level=0, + country=country, + cover=cover, + kudosu=kudosu, + statistics=statistics, + statistics_rulesets=statistics_rulesets, + beatmap_playcounts_count=3306, + comments_count=0, + favourite_beatmapset_count=0, + follower_count=0, + graveyard_beatmapset_count=0, + guest_beatmapset_count=0, + loved_beatmapset_count=0, + mapping_follower_count=0, + nominated_beatmapset_count=0, + pending_beatmapset_count=0, + ranked_beatmapset_count=0, + ranked_and_approved_beatmapset_count=0, + unranked_beatmapset_count=0, + scores_best_count=0, + scores_first_count=0, + scores_pinned_count=0, + scores_recent_count=0, + account_history=[], + active_tournament_banner=None, + active_tournament_banners=[], + badges=[], + current_season_stats=None, + daily_challenge_user_stats=None, + groups=[], + monthly_playcounts=[], + #page=Page(html=db_user.page_html, raw=db_user.page_raw), + page=Page(), # Provide a default Page object + previous_usernames=[], + rank_highest=rank_highest, + rank_history=rank_history, + rankHistory=rank_history, # 兼容性别名 + replays_watched_counts=[], + team=team, + user_achievements=user_achievements + ) + + return user + + +def get_country_name(country_code: str) -> str: + """根据国家代码获取国家名称""" + country_names = { + "CN": "China", + "JP": "Japan", + "US": "United States", + "GB": "United Kingdom", + "DE": "Germany", + "FR": "France", + "KR": "South Korea", + "CA": "Canada", + "AU": "Australia", + "BR": "Brazil", + # 可以添加更多国家 + } + return country_names.get(country_code, "Unknown") + + +def create_default_profile(db_user: DBUser): + """创建默认的用户资料""" + class MockProfile: + def __init__(self): + self.is_active = True + self.is_bot = False + self.is_deleted = False + self.is_online = True + self.is_supporter = False + self.is_restricted = False + self.session_verified = False + self.has_supported = False + self.pm_friends_only = False + self.default_group = 'default' + self.last_visit = None + self.join_date = db_user.join_date + self.profile_colour = None + self.profile_hue = None + self.avatar_url = None + self.cover_url = None + self.discord = None + self.twitter = None + self.website = None + self.title = None + self.title_url = None + self.interests = None + self.location = None + self.occupation = None + self.playmode = 'osu' + self.support_level = 0 + self.max_blocks = 100 + self.max_friends = 500 + self.post_count = 0 + self.page_html = None + self.page_raw = None + + return MockProfile() + + +def create_default_lazer_statistics(mode: str): + """创建默认的 Lazer 统计信息""" + class MockLazerStatistics: + def __init__(self, mode: str): + self.mode = mode + self.count_100 = 0 + self.count_300 = 0 + self.count_50 = 0 + self.count_miss = 0 + self.level_current = 1 + self.level_progress = 0 + self.global_rank = None + self.global_rank_exp = None + self.pp = 0.0 + self.pp_exp = 0.0 + self.ranked_score = 0 + self.hit_accuracy = 0.0 + self.play_count = 0 + self.play_time = 0 + self.total_score = 0 + self.total_hits = 0 + self.maximum_combo = 0 + self.replays_watched_by_others = 0 + self.is_ranked = False + self.grade_ss = 0 + self.grade_ssh = 0 + self.grade_s = 0 + self.grade_sh = 0 + self.grade_a = 0 + self.country_rank = None + self.rank_highest = None + self.rank_highest_updated_at = None + + return MockLazerStatistics(mode) + + +def create_default_country(country_code: str): + """创建默认的国家信息""" + class MockCountry: + def __init__(self, code: str): + self.code = code + self.name = get_country_name(code) + + return MockCountry(country_code) + + +def create_default_kudosu(): + """创建默认的 Kudosu 信息""" + class MockKudosu: + def __init__(self): + self.available = 0 + self.total = 0 + + return MockKudosu() + + +def create_default_counts(): + """创建默认的计数信息""" + class MockCounts: + def __init__(self): + self.beatmap_playcounts_count = 0 + self.comments_count = 0 + self.favourite_beatmapset_count = 0 + self.follower_count = 0 + self.graveyard_beatmapset_count = 0 + self.guest_beatmapset_count = 0 + self.loved_beatmapset_count = 0 + self.mapping_follower_count = 0 + self.nominated_beatmapset_count = 0 + self.pending_beatmapset_count = 0 + self.ranked_beatmapset_count = 0 + self.ranked_and_approved_beatmapset_count = 0 + self.unranked_beatmapset_count = 0 + self.scores_best_count = 0 + self.scores_first_count = 0 + self.scores_pinned_count = 0 + self.scores_recent_count = 0 + + return MockCounts() diff --git a/create_sample_data.py b/create_sample_data.py new file mode 100644 index 0000000..e6bf457 --- /dev/null +++ b/create_sample_data.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +""" +osu! API 模拟服务器的示例数据填充脚本 +""" + +from datetime import datetime, timedelta +import time +from sqlalchemy.orm import Session +from app.dependencies import get_db, engine +from app.database import Base, User, UserStatistics, UserAchievement, DailyChallengeStats, RankHistory +from app.auth import get_password_hash + +# 创建所有表 +Base.metadata.create_all(bind=engine) + +def create_sample_user(): + """创建示例用户数据""" + db = next(get_db()) + + # 检查用户是否已存在 + existing_user = db.query(User).filter(User.name == "Googujiang").first() + if existing_user: + print("示例用户已存在,跳过创建") + return existing_user + + # 当前时间戳 + current_timestamp = int(time.time()) + join_timestamp = int(datetime(2019, 11, 29, 17, 23, 13).timestamp()) + last_visit_timestamp = int(datetime(2025, 7, 18, 16, 31, 29).timestamp()) + + # 创建用户 + user = User( + name="Googujiang", + safe_name="googujiang", # 安全用户名(小写) + email="googujiang@example.com", + priv=1, # 默认权限 + pw_bcrypt=get_password_hash("password123"), # 使用新的哈希方式 + country="JP", + silence_end=0, + donor_end=0, + creation_time=join_timestamp, + latest_activity=last_visit_timestamp, + clan_id=0, + clan_priv=0, + preferred_mode=0, # 0 = osu! + play_style=0, + custom_badge_name=None, + custom_badge_icon=None, + userpage_content="「世界に忘れられた」", + api_key=None, + + # 兼容性字段 + avatar_url="https://a.ppy.sh/15651670?1732362658.jpeg", + cover_url="https://assets.ppy.sh/user-profile-covers/15651670/0fc7b77adef39765a570e7f535bc383e5a848850d41a8943f8857984330b8bc6.jpeg", + has_supported=True, + interests="「世界に忘れられた」", + location="咕谷国", + website="https://gmoe.cc", + playstyle=["mouse", "keyboard", "tablet"], + profile_order=["me", "recent_activity", "top_ranks", "medals", "historical", "beatmaps", "kudosu"], + beatmap_playcounts_count=3306, + favourite_beatmapset_count=15, + follower_count=98, + graveyard_beatmapset_count=7, + mapping_follower_count=1, + previous_usernames=["hehejun"], + monthly_playcounts=[ + {"start_date": "2019-11-01", "count": 43}, + {"start_date": "2020-04-01", "count": 216}, + {"start_date": "2020-05-01", "count": 656}, + {"start_date": "2020-07-01", "count": 158}, + {"start_date": "2020-08-01", "count": 174}, + {"start_date": "2020-10-01", "count": 13}, + {"start_date": "2020-11-01", "count": 52}, + {"start_date": "2020-12-01", "count": 140}, + {"start_date": "2021-01-01", "count": 359}, + {"start_date": "2021-02-01", "count": 452}, + {"start_date": "2021-03-01", "count": 77}, + {"start_date": "2021-04-01", "count": 114}, + {"start_date": "2021-05-01", "count": 270}, + {"start_date": "2021-06-01", "count": 148}, + {"start_date": "2021-07-01", "count": 246}, + {"start_date": "2021-08-01", "count": 56}, + {"start_date": "2021-09-01", "count": 136}, + {"start_date": "2021-10-01", "count": 45}, + {"start_date": "2021-11-01", "count": 98}, + {"start_date": "2021-12-01", "count": 54}, + {"start_date": "2022-01-01", "count": 88}, + {"start_date": "2022-02-01", "count": 45}, + {"start_date": "2022-03-01", "count": 6}, + {"start_date": "2022-04-01", "count": 54}, + {"start_date": "2022-05-01", "count": 105}, + {"start_date": "2022-06-01", "count": 37}, + {"start_date": "2022-07-01", "count": 88}, + {"start_date": "2022-08-01", "count": 7}, + {"start_date": "2022-09-01", "count": 9}, + {"start_date": "2022-10-01", "count": 6}, + {"start_date": "2022-11-01", "count": 2}, + {"start_date": "2022-12-01", "count": 16}, + {"start_date": "2023-01-01", "count": 7}, + {"start_date": "2023-04-01", "count": 16}, + {"start_date": "2023-05-01", "count": 3}, + {"start_date": "2023-06-01", "count": 8}, + {"start_date": "2023-07-01", "count": 23}, + {"start_date": "2023-08-01", "count": 3}, + {"start_date": "2023-09-01", "count": 1}, + {"start_date": "2023-10-01", "count": 25}, + {"start_date": "2023-11-01", "count": 160}, + {"start_date": "2023-12-01", "count": 306}, + {"start_date": "2024-01-01", "count": 735}, + {"start_date": "2024-02-01", "count": 420}, + {"start_date": "2024-03-01", "count": 549}, + {"start_date": "2024-04-01", "count": 466}, + {"start_date": "2024-05-01", "count": 333}, + {"start_date": "2024-06-01", "count": 1126}, + {"start_date": "2024-07-01", "count": 534}, + {"start_date": "2024-08-01", "count": 280}, + {"start_date": "2024-09-01", "count": 116}, + {"start_date": "2024-10-01", "count": 120}, + {"start_date": "2024-11-01", "count": 332}, + {"start_date": "2024-12-01", "count": 243}, + {"start_date": "2025-01-01", "count": 122}, + {"start_date": "2025-02-01", "count": 379}, + {"start_date": "2025-03-01", "count": 278}, + {"start_date": "2025-04-01", "count": 296}, + {"start_date": "2025-05-01", "count": 964}, + {"start_date": "2025-06-01", "count": 821}, + {"start_date": "2025-07-01", "count": 230} + ] + ) + + db.add(user) + db.commit() + db.refresh(user) + + # 创建 osu! 模式统计 + osu_stats = UserStatistics( + user_id=user.id, + mode="osu", + count_100=276274, + count_300=1932068, + count_50=32776, + count_miss=111064, + level_current=97, + level_progress=96, + global_rank=298026, + country_rank=11221, + pp=2826.26, + ranked_score=4415081049, + hit_accuracy=95.7168, + play_count=12711, + play_time=836529, + total_score=12390140370, + total_hits=2241118, + maximum_combo=1859, + replays_watched_by_others=0, + is_ranked=True, + grade_ss=14, + grade_ssh=3, + grade_s=322, + grade_sh=11, + grade_a=757, + rank_highest=295701, + rank_highest_updated_at=datetime(2025, 7, 2, 17, 30, 21) + ) + + # 创建 taiko 模式统计 + taiko_stats = UserStatistics( + user_id=user.id, + mode="taiko", + count_100=160, + count_300=154, + count_50=0, + count_miss=480, + level_current=2, + level_progress=49, + global_rank=None, + pp=0, + ranked_score=0, + hit_accuracy=0, + play_count=6, + play_time=217, + total_score=79301, + total_hits=314, + maximum_combo=0, + replays_watched_by_others=0, + is_ranked=False + ) + + # 创建 fruits 模式统计 + fruits_stats = UserStatistics( + user_id=user.id, + mode="fruits", + count_100=109, + count_300=1613, + count_50=1861, + count_miss=328, + level_current=6, + level_progress=14, + global_rank=None, + pp=0, + ranked_score=343854, + hit_accuracy=89.4779, + play_count=19, + play_time=669, + total_score=1362651, + total_hits=3583, + maximum_combo=75, + replays_watched_by_others=0, + is_ranked=False, + grade_a=1 + ) + + # 创建 mania 模式统计 + mania_stats = UserStatistics( + user_id=user.id, + mode="mania", + count_100=7867, + count_300=12104, + count_50=991, + count_miss=2951, + level_current=12, + level_progress=89, + global_rank=660670, + pp=25.3784, + ranked_score=3812295, + hit_accuracy=77.9316, + play_count=85, + play_time=4834, + total_score=13454470, + total_hits=20962, + maximum_combo=573, + replays_watched_by_others=0, + is_ranked=True, + grade_a=1 + ) + + db.add_all([osu_stats, taiko_stats, fruits_stats, mania_stats]) + + # 创建每日挑战统计 + daily_challenge = DailyChallengeStats( + user_id=user.id, + daily_streak_best=1, + daily_streak_current=0, + last_update=datetime(2025, 6, 21, 0, 0, 0), + last_weekly_streak=datetime(2025, 6, 19, 0, 0, 0), + playcount=1, + top_10p_placements=0, + top_50p_placements=0, + weekly_streak_best=1, + weekly_streak_current=0 + ) + + db.add(daily_challenge) + + # 创建排名历史 (最近90天的数据) + rank_data = [322806, 323092, 323341, 323616, 323853, 324106, 324378, 324676, 324958, 325254, 325492, 325780, 326075, 326356, 326586, 326845, 327067, 327286, 327526, 327778, 328039, 328347, 328631, 328858, 329323, 329557, 329809, 329911, 330188, 330425, 330650, 330881, 331068, 331325, 331575, 331816, 332061, 328959, 315648, 315881, 308784, 309023, 309252, 309433, 309537, 309364, 309548, 308957, 309182, 309426, 309607, 309831, 310054, 310269, 310485, 310714, 310956, 310924, 311125, 311203, 311422, 311640, 303091, 303309, 303500, 303691, 303758, 303750, 303957, 299867, 300088, 300273, 300457, 295799, 295976, 296153, 296350, 296566, 296756, 296933, 297141, 297314, 297480, 297114, 297296, 297480, 297645, 297815, 297993, 298026] + + rank_history = RankHistory( + user_id=user.id, + mode="osu", + rank_data=rank_data + ) + + db.add(rank_history) + + # 创建一些成就 + achievements = [ + UserAchievement(user_id=user.id, achievement_id=336, achieved_at=datetime(2025, 6, 21, 19, 6, 32)), + UserAchievement(user_id=user.id, achievement_id=319, achieved_at=datetime(2025, 6, 1, 0, 52, 0)), + UserAchievement(user_id=user.id, achievement_id=222, achieved_at=datetime(2025, 5, 28, 12, 24, 37)), + UserAchievement(user_id=user.id, achievement_id=38, achieved_at=datetime(2024, 7, 5, 15, 43, 23)), + UserAchievement(user_id=user.id, achievement_id=67, achieved_at=datetime(2024, 6, 24, 5, 6, 44)), + ] + + db.add_all(achievements) + + db.commit() + print(f"成功创建示例用户: {user.name} (ID: {user.id})") + print(f"安全用户名: {user.safe_name}") + print(f"邮箱: {user.email}") + print(f"国家: {user.country}") + return user + + +if __name__ == "__main__": + print("开始创建示例数据...") + user = create_sample_user() + print("示例数据创建完成!") + print(f"用户名: {user.name}") + print(f"密码: password123") + print("现在您可以使用这些凭据来测试API了。") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9eceee3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,50 @@ +version: '3.8' + +services: + mysql: + image: mysql:8.0 + container_name: osu_api_mysql + environment: + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: osu_api + MYSQL_USER: osu_user + MYSQL_PASSWORD: osu_password + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + - ./mysql-init:/docker-entrypoint-initdb.d + restart: unless-stopped + + redis: + image: redis:7-alpine + container_name: osu_api_redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + restart: unless-stopped + command: redis-server --appendonly yes + + api: + build: . + container_name: osu_api_server + ports: + - "8000:8000" + environment: + DATABASE_URL: mysql+pymysql://osu_user:osu_password@mysql:3306/osu_api + REDIS_URL: redis://redis:6379/0 + SECRET_KEY: your-production-secret-key-here + OSU_CLIENT_ID: "5" + OSU_CLIENT_SECRET: "FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk" + depends_on: + - mysql + - redis + restart: unless-stopped + volumes: + - ./:/app + command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload + +volumes: + mysql_data: + redis_data: diff --git a/main.py b/main.py new file mode 100644 index 0000000..12780fb --- /dev/null +++ b/main.py @@ -0,0 +1,279 @@ +from fastapi import FastAPI, Depends, HTTPException, Form +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from fastapi.responses import JSONResponse +from sqlalchemy.orm import Session +from typing import Optional +from datetime import datetime, timedelta + +from app.config import settings +from app.dependencies import get_db, get_redis, engine +from app.models import TokenRequest, TokenResponse, User as ApiUser +from app.database import Base, User as DBUser +from app.auth import authenticate_user, create_access_token, generate_refresh_token, store_token +from app.auth import get_token_by_access_token, get_token_by_refresh_token, verify_token +from app.utils import convert_db_user_to_api_user + +# 注意: 表结构现在通过 migrations 管理,不再自动创建 +# 如需创建表,请运行: python quick_sync.py + +app = FastAPI(title="osu! API 模拟服务器", version="1.0.0") + +security = HTTPBearer() + + +@app.post("/oauth/token", response_model=TokenResponse) +async def oauth_token( + grant_type: str = Form(...), + client_id: str = Form(...), + client_secret: str = Form(...), + scope: str = Form("*"), + username: Optional[str] = Form(None), + password: Optional[str] = Form(None), + refresh_token: Optional[str] = Form(None), + db: Session = Depends(get_db) +): + """OAuth 令牌端点""" + # 验证客户端凭据 + if client_id != settings.OSU_CLIENT_ID or client_secret != settings.OSU_CLIENT_SECRET: + raise HTTPException(status_code=401, detail="Invalid client credentials") + + if grant_type == "password": + # 密码授权流程 + if not username or not password: + raise HTTPException(status_code=400, detail="Username and password required") + + # 验证用户 + user = authenticate_user(db, username, password) + if not user: + raise HTTPException(status_code=401, detail="Invalid username or password") + + # 生成令牌 + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": str(user.id)}, expires_delta=access_token_expires + ) + refresh_token_str = generate_refresh_token() + + # 存储令牌 + store_token( + db, + user.id, + access_token, + refresh_token_str, + settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60 + ) + + return TokenResponse( + access_token=access_token, + token_type="Bearer", + expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + refresh_token=refresh_token_str, + scope=scope + ) + + elif grant_type == "refresh_token": + # 刷新令牌流程 + if not refresh_token: + raise HTTPException(status_code=400, detail="Refresh token required") + + # 验证刷新令牌 + token_record = get_token_by_refresh_token(db, refresh_token) + if not token_record: + raise HTTPException(status_code=401, detail="Invalid refresh token") + + # 生成新的访问令牌 + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": str(token_record.user_id)}, expires_delta=access_token_expires + ) + new_refresh_token = generate_refresh_token() + + # 更新令牌 + store_token( + db, + token_record.user_id, + access_token, + new_refresh_token, + settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60 + ) + + return TokenResponse( + access_token=access_token, + token_type="Bearer", + expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + refresh_token=new_refresh_token, + scope=scope + ) + + else: + raise HTTPException(status_code=400, detail="Unsupported grant type") + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db) +) -> DBUser: + """获取当前认证用户""" + token = credentials.credentials + + # 验证令牌 + token_record = get_token_by_access_token(db, token) + if not token_record: + raise HTTPException(status_code=401, detail="Invalid or expired token") + + # 获取用户 + user = db.query(DBUser).filter(DBUser.id == token_record.user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + return user + + +@app.get("/api/v2/me", response_model=ApiUser) +@app.get("/api/v2/me/", response_model=ApiUser) +async def get_user_info_default( + current_user: DBUser = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取当前用户信息(默认使用osu模式)""" + # 默认使用osu模式 + api_user = convert_db_user_to_api_user(current_user, "osu", db) + return api_user + + +@app.get("/api/v2/me/{ruleset}", response_model=ApiUser) +async def get_user_info( + ruleset: str = "osu", + current_user: DBUser = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取当前用户信息""" + + # 验证游戏模式 + valid_rulesets = ["osu", "taiko", "fruits", "mania"] + if ruleset not in valid_rulesets: + raise HTTPException(status_code=400, detail=f"Invalid ruleset. Must be one of: {valid_rulesets}") + + # 转换用户数据 + api_user = convert_db_user_to_api_user(current_user, ruleset, db) + return api_user + + +@app.get("/") +async def root(): + """根端点""" + return {"message": "osu! API 模拟服务器正在运行"} + + +@app.get("/health") +async def health_check(): + """健康检查端点""" + return {"status": "ok", "timestamp": datetime.utcnow().isoformat()} + + + +@app.get("/api/v2/friends") +async def get_friends(): + return JSONResponse(content=[ + { + "id": 123456, + "username": "BestFriend", + "is_online": True, + "is_supporter": False, + "country": {"code": "US", "name": "United States"} + } + ]) + + +@app.get("/api/v2/notifications") +async def get_notifications(): + return JSONResponse(content={ + "notifications": [], + "unread_count": 0 + }) + + +@app.post("/api/v2/chat/ack") +async def chat_ack(): + return JSONResponse(content={"status": "ok"}) + + +@app.get("/api/v2/users/{user_id}/{mode}") +async def get_user_mode(user_id: int, mode: str): + return JSONResponse(content={ + "id": user_id, + "username": "测试测试测", + "statistics": { + "level": {"current": 97, "progress": 96}, + "pp": 114514, + "global_rank": 666, + "country_rank": 1, + "hit_accuracy": 100 + }, + "country": {"code": "JP", "name": "Japan"} + }) + + +@app.get("/api/v2/me") +async def get_me(): + return JSONResponse(content={ + "id": 15651670, + "username": "Googujiang", + "is_online": True, + "country": {"code": "JP", "name": "Japan"}, + "statistics": { + "level": {"current": 97, "progress": 96}, + "pp": 2826.26, + "global_rank": 298026, + "country_rank": 11220, + "hit_accuracy": 95.7168 + } + }) + + +@app.post("/signalr/metadata/negotiate") +async def metadata_negotiate(negotiateVersion: int = 1): + return JSONResponse(content={ + "connectionId": "abc123", + "availableTransports": [ + { + "transport": "WebSockets", + "transferFormats": ["Text", "Binary"] + } + ] + }) + + +@app.post("/signalr/spectator/negotiate") +async def spectator_negotiate(negotiateVersion: int = 1): + return JSONResponse(content={ + "connectionId": "spec456", + "availableTransports": [ + { + "transport": "WebSockets", + "transferFormats": ["Text", "Binary"] + } + ] + }) + + +@app.post("/signalr/multiplayer/negotiate") +async def multiplayer_negotiate(negotiateVersion: int = 1): + return JSONResponse(content={ + "connectionId": "multi789", + "availableTransports": [ + { + "transport": "WebSockets", + "transferFormats": ["Text", "Binary"] + } + ] + }) + +if __name__ == "__main__": + import uvicorn + uvicorn.run( + "main:app", + host=settings.HOST, + port=settings.PORT, + reload=settings.DEBUG + ) diff --git a/migrations/add_lazer_rank_fields.sql b/migrations/add_lazer_rank_fields.sql new file mode 100644 index 0000000..1878b96 --- /dev/null +++ b/migrations/add_lazer_rank_fields.sql @@ -0,0 +1,16 @@ +-- 创建迁移日志表(如果不存在) +CREATE TABLE IF NOT EXISTS `migration_logs` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `version` VARCHAR(50) NOT NULL, + `description` VARCHAR(255) NOT NULL, + `timestamp` DATETIME NOT NULL +); + +-- 向 lazer_user_statistics 表添加缺失的字段 +ALTER TABLE `lazer_user_statistics` +ADD COLUMN IF NOT EXISTS `rank_highest` INT NULL COMMENT '最高排名' AFTER `grade_a`, +ADD COLUMN IF NOT EXISTS `rank_highest_updated_at` DATETIME NULL COMMENT '最高排名更新时间' AFTER `rank_highest`; + +-- 更新日志 +INSERT INTO `migration_logs` (`version`, `description`, `timestamp`) +VALUES ('20250719', '向 lazer_user_statistics 表添加缺失的字段', NOW()); diff --git a/migrations/add_missing_fields.sql b/migrations/add_missing_fields.sql new file mode 100644 index 0000000..4627f84 --- /dev/null +++ b/migrations/add_missing_fields.sql @@ -0,0 +1,421 @@ +-- Lazer API 专用数据表创建脚本 +-- 基于真实 osu! API 返回数据设计的表结构 +-- 完全不修改 bancho.py 原有表结构,创建全新的 lazer 专用表 + +-- ============================================ +-- Lazer API 专用扩展表 +-- ============================================ + +-- Lazer 用户扩展信息表 +CREATE TABLE IF NOT EXISTS lazer_user_profiles ( + user_id INT PRIMARY KEY COMMENT '关联 users.id', + + -- 基本状态字段 + is_active TINYINT(1) DEFAULT 1 COMMENT '用户是否激活', + is_bot TINYINT(1) DEFAULT 0 COMMENT '是否为机器人账户', + is_deleted TINYINT(1) DEFAULT 0 COMMENT '是否已删除', + is_online TINYINT(1) DEFAULT 1 COMMENT '是否在线', + is_supporter TINYINT(1) DEFAULT 0 COMMENT '是否为支持者', + is_restricted TINYINT(1) DEFAULT 0 COMMENT '是否被限制', + session_verified TINYINT(1) DEFAULT 0 COMMENT '会话是否已验证', + has_supported TINYINT(1) DEFAULT 0 COMMENT '是否曾经支持过', + pm_friends_only TINYINT(1) DEFAULT 0 COMMENT '是否只接受好友私信', + + -- 基本资料字段 + default_group VARCHAR(50) DEFAULT 'default' COMMENT '默认用户组', + last_visit DATETIME NULL COMMENT '最后访问时间', + join_date DATETIME NULL COMMENT '加入日期', + profile_colour VARCHAR(7) NULL COMMENT '个人资料颜色', + profile_hue INT NULL COMMENT '个人资料色调', + + -- 社交媒体和个人资料字段 + avatar_url VARCHAR(500) NULL COMMENT '头像URL', + cover_url VARCHAR(500) NULL COMMENT '封面URL', + discord VARCHAR(100) NULL COMMENT 'Discord用户名', + twitter VARCHAR(100) NULL COMMENT 'Twitter用户名', + website VARCHAR(500) NULL COMMENT '个人网站', + title VARCHAR(100) NULL COMMENT '用户称号', + title_url VARCHAR(500) NULL COMMENT '称号链接', + interests TEXT NULL COMMENT '兴趣爱好', + location VARCHAR(100) NULL COMMENT '地理位置', + occupation VARCHAR(100) NULL COMMENT '职业', + + -- 游戏相关字段 + playmode VARCHAR(10) DEFAULT 'osu' COMMENT '主要游戏模式', + support_level INT DEFAULT 0 COMMENT '支持者等级', + max_blocks INT DEFAULT 100 COMMENT '最大屏蔽数量', + max_friends INT DEFAULT 500 COMMENT '最大好友数量', + post_count INT DEFAULT 0 COMMENT '帖子数量', + + -- 页面内容 + page_html TEXT NULL COMMENT '个人页面HTML', + page_raw TEXT NULL COMMENT '个人页面原始内容', + + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Lazer API 用户扩展资料表'; + +-- 用户封面信息表 +CREATE TABLE IF NOT EXISTS lazer_user_covers ( + user_id INT PRIMARY KEY COMMENT '关联 users.id', + custom_url VARCHAR(500) NULL COMMENT '自定义封面URL', + url VARCHAR(500) NULL COMMENT '封面URL', + cover_id INT NULL COMMENT '封面ID', + + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户封面信息表'; + +-- 用户国家信息表 +CREATE TABLE IF NOT EXISTS lazer_user_countries ( + user_id INT PRIMARY KEY COMMENT '关联 users.id', + code VARCHAR(2) NOT NULL COMMENT '国家代码', + name VARCHAR(100) NOT NULL COMMENT '国家名称', + + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户国家信息表'; + +-- 用户 Kudosu 表 +CREATE TABLE IF NOT EXISTS lazer_user_kudosu ( + user_id INT PRIMARY KEY COMMENT '关联 users.id', + available INT DEFAULT 0 COMMENT '可用 Kudosu', + total INT DEFAULT 0 COMMENT '总 Kudosu', + + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户 Kudosu 表'; + +-- 用户统计计数表 +CREATE TABLE IF NOT EXISTS lazer_user_counts ( + user_id INT PRIMARY KEY COMMENT '关联 users.id', + + -- 统计计数字段 + beatmap_playcounts_count INT DEFAULT 0 COMMENT '谱面游玩次数统计', + comments_count INT DEFAULT 0 COMMENT '评论数量', + favourite_beatmapset_count INT DEFAULT 0 COMMENT '收藏谱面集数量', + follower_count INT DEFAULT 0 COMMENT '关注者数量', + graveyard_beatmapset_count INT DEFAULT 0 COMMENT '坟场谱面集数量', + guest_beatmapset_count INT DEFAULT 0 COMMENT '客串谱面集数量', + loved_beatmapset_count INT DEFAULT 0 COMMENT '被喜爱谱面集数量', + mapping_follower_count INT DEFAULT 0 COMMENT '作图关注者数量', + nominated_beatmapset_count INT DEFAULT 0 COMMENT '提名谱面集数量', + pending_beatmapset_count INT DEFAULT 0 COMMENT '待审核谱面集数量', + ranked_beatmapset_count INT DEFAULT 0 COMMENT 'Ranked谱面集数量', + ranked_and_approved_beatmapset_count INT DEFAULT 0 COMMENT 'Ranked+Approved谱面集数量', + unranked_beatmapset_count INT DEFAULT 0 COMMENT '未Ranked谱面集数量', + scores_best_count INT DEFAULT 0 COMMENT '最佳成绩数量', + scores_first_count INT DEFAULT 0 COMMENT '第一名成绩数量', + scores_pinned_count INT DEFAULT 0 COMMENT '置顶成绩数量', + scores_recent_count INT DEFAULT 0 COMMENT '最近成绩数量', + + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Lazer API 用户统计计数表'; + +-- 用户游戏风格表 (替代 playstyle JSON) +CREATE TABLE IF NOT EXISTS lazer_user_playstyles ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL COMMENT '关联 users.id', + style VARCHAR(50) NOT NULL COMMENT '游戏风格: mouse, keyboard, tablet, touch', + + INDEX idx_user_id (user_id), + UNIQUE KEY unique_user_style (user_id, style), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户游戏风格表'; + +-- 用户个人资料显示顺序表 (替代 profile_order JSON) +CREATE TABLE IF NOT EXISTS lazer_user_profile_sections ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL COMMENT '关联 users.id', + section_name VARCHAR(50) NOT NULL COMMENT '部分名称', + display_order INT DEFAULT 0 COMMENT '显示顺序', + + INDEX idx_user_id (user_id), + INDEX idx_order (user_id, display_order), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户个人资料显示顺序表'; + +-- 用户账户历史表 (替代 account_history JSON) +CREATE TABLE IF NOT EXISTS lazer_user_account_history ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL COMMENT '关联 users.id', + event_type VARCHAR(50) NOT NULL COMMENT '事件类型', + description TEXT COMMENT '事件描述', + length INT COMMENT '持续时间(秒)', + permanent TINYINT(1) DEFAULT 0 COMMENT '是否永久', + event_time DATETIME NOT NULL COMMENT '事件时间', + + INDEX idx_user_id (user_id), + INDEX idx_event_time (event_time), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户账户历史表'; + +-- 用户历史用户名表 (替代 previous_usernames JSON) +CREATE TABLE IF NOT EXISTS lazer_user_previous_usernames ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL COMMENT '关联 users.id', + username VARCHAR(32) NOT NULL COMMENT '历史用户名', + changed_at DATETIME NOT NULL COMMENT '更改时间', + + INDEX idx_user_id (user_id), + INDEX idx_changed_at (changed_at), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户历史用户名表'; + +-- 用户月度游戏次数表 (替代 monthly_playcounts JSON) +CREATE TABLE IF NOT EXISTS lazer_user_monthly_playcounts ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL COMMENT '关联 users.id', + start_date DATE NOT NULL COMMENT '月份开始日期', + play_count INT DEFAULT 0 COMMENT '游戏次数', + + INDEX idx_user_id (user_id), + INDEX idx_start_date (start_date), + UNIQUE KEY unique_user_month (user_id, start_date), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户月度游戏次数表'; + +-- 用户最高排名表 (rank_highest) +CREATE TABLE IF NOT EXISTS lazer_user_rank_highest ( + user_id INT PRIMARY KEY COMMENT '关联 users.id', + rank_position INT NOT NULL COMMENT '最高排名位置', + updated_at DATETIME NOT NULL COMMENT '更新时间', + + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户最高排名表'; + +-- ============================================ +-- OAuth 令牌表 (Lazer API 专用) +-- ============================================ +CREATE TABLE IF NOT EXISTS lazer_oauth_tokens ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + access_token VARCHAR(255) NOT NULL, + refresh_token VARCHAR(255) NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_user_id (user_id), + INDEX idx_access_token (access_token), + INDEX idx_refresh_token (refresh_token), + INDEX idx_expires_at (expires_at), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Lazer API OAuth访问令牌表'; + +-- ============================================ +-- 用户统计数据表 (基于真实 API 数据结构) +-- ============================================ + +-- 用户主要统计表 (statistics 字段) +CREATE TABLE IF NOT EXISTS lazer_user_statistics ( + user_id INT NOT NULL, + mode VARCHAR(10) NOT NULL DEFAULT 'osu' COMMENT '游戏模式: osu, taiko, fruits, mania', + + -- 基本命中统计 + count_100 INT DEFAULT 0 COMMENT '100分命中数', + count_300 INT DEFAULT 0 COMMENT '300分命中数', + count_50 INT DEFAULT 0 COMMENT '50分命中数', + count_miss INT DEFAULT 0 COMMENT 'Miss数', + + -- 等级信息 + level_current INT DEFAULT 1 COMMENT '当前等级', + level_progress INT DEFAULT 0 COMMENT '等级进度', + + -- 排名信息 + global_rank INT NULL COMMENT '全球排名', + global_rank_exp INT NULL COMMENT '全球排名(实验性)', + country_rank INT NULL COMMENT '国家/地区排名', + + -- PP 和分数 + pp DECIMAL(10,2) DEFAULT 0.00 COMMENT 'Performance Points', + pp_exp DECIMAL(10,2) DEFAULT 0.00 COMMENT 'PP(实验性)', + ranked_score BIGINT DEFAULT 0 COMMENT 'Ranked分数', + hit_accuracy DECIMAL(5,2) DEFAULT 0.00 COMMENT '命中精度', + total_score BIGINT DEFAULT 0 COMMENT '总分数', + total_hits BIGINT DEFAULT 0 COMMENT '总命中数', + maximum_combo INT DEFAULT 0 COMMENT '最大连击', + + -- 游戏统计 + play_count INT DEFAULT 0 COMMENT '游戏次数', + play_time INT DEFAULT 0 COMMENT '游戏时间(秒)', + replays_watched_by_others INT DEFAULT 0 COMMENT '被观看的Replay次数', + is_ranked TINYINT(1) DEFAULT 0 COMMENT '是否有排名', + + -- 成绩等级计数 (grade_counts) + grade_ss INT DEFAULT 0 COMMENT 'SS等级数', + grade_ssh INT DEFAULT 0 COMMENT 'SSH等级数', + grade_s INT DEFAULT 0 COMMENT 'S等级数', + grade_sh INT DEFAULT 0 COMMENT 'SH等级数', + grade_a INT DEFAULT 0 COMMENT 'A等级数', + + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + PRIMARY KEY (user_id, mode), + INDEX idx_mode (mode), + INDEX idx_global_rank (global_rank), + INDEX idx_country_rank (country_rank), + INDEX idx_pp (pp), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Lazer API 用户游戏统计表'; + +-- 每日挑战用户统计表 (daily_challenge_user_stats) +CREATE TABLE IF NOT EXISTS lazer_daily_challenge_stats ( + user_id INT PRIMARY KEY COMMENT '关联 users.id', + daily_streak_best INT DEFAULT 0 COMMENT '最佳每日连击', + daily_streak_current INT DEFAULT 0 COMMENT '当前每日连击', + last_update DATE NULL COMMENT '最后更新日期', + last_weekly_streak DATE NULL COMMENT '最后周连击日期', + playcount INT DEFAULT 0 COMMENT '游戏次数', + top_10p_placements INT DEFAULT 0 COMMENT 'Top 10% 位置数', + top_50p_placements INT DEFAULT 0 COMMENT 'Top 50% 位置数', + weekly_streak_best INT DEFAULT 0 COMMENT '最佳周连击', + weekly_streak_current INT DEFAULT 0 COMMENT '当前周连击', + + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='每日挑战用户统计表'; + +-- 用户团队信息表 (team 字段) +CREATE TABLE IF NOT EXISTS lazer_user_teams ( + user_id INT PRIMARY KEY COMMENT '关联 users.id', + team_id INT NOT NULL COMMENT '团队ID', + team_name VARCHAR(100) NOT NULL COMMENT '团队名称', + team_short_name VARCHAR(10) NOT NULL COMMENT '团队简称', + flag_url VARCHAR(500) NULL COMMENT '团队旗帜URL', + + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户团队信息表'; + +-- 用户成就表 (user_achievements) +CREATE TABLE IF NOT EXISTS lazer_user_achievements ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL COMMENT '关联 users.id', + achievement_id INT NOT NULL COMMENT '成就ID', + achieved_at DATETIME NOT NULL COMMENT '获得时间', + + INDEX idx_user_id (user_id), + INDEX idx_achievement_id (achievement_id), + INDEX idx_achieved_at (achieved_at), + UNIQUE KEY unique_user_achievement (user_id, achievement_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户成就表'; + +-- 用户排名历史表 (rank_history) +CREATE TABLE IF NOT EXISTS lazer_user_rank_history ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL COMMENT '关联 users.id', + mode VARCHAR(10) NOT NULL DEFAULT 'osu' COMMENT '游戏模式', + day_offset INT NOT NULL COMMENT '天数偏移量(从某个基准日期开始)', + rank_position INT NOT NULL COMMENT '排名位置', + + INDEX idx_user_mode (user_id, mode), + INDEX idx_day_offset (day_offset), + UNIQUE KEY unique_user_mode_day (user_id, mode, day_offset), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户排名历史表'; + +-- Replay 观看次数表 (replays_watched_counts) +CREATE TABLE IF NOT EXISTS lazer_user_replays_watched ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL COMMENT '关联 users.id', + start_date DATE NOT NULL COMMENT '开始日期', + count INT DEFAULT 0 COMMENT '观看次数', + + INDEX idx_user_id (user_id), + INDEX idx_start_date (start_date), + UNIQUE KEY unique_user_date (user_id, start_date), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户Replay观看次数表'; + +-- 用户徽章表 (badges) +CREATE TABLE IF NOT EXISTS lazer_user_badges ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL COMMENT '关联 users.id', + badge_id INT NOT NULL COMMENT '徽章ID', + awarded_at DATETIME NULL COMMENT '授予时间', + description TEXT NULL COMMENT '徽章描述', + image_url VARCHAR(500) NULL COMMENT '徽章图片URL', + url VARCHAR(500) NULL COMMENT '徽章链接', + + INDEX idx_user_id (user_id), + INDEX idx_badge_id (badge_id), + UNIQUE KEY unique_user_badge (user_id, badge_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户徽章表'; + +-- 用户组表 (groups) +CREATE TABLE IF NOT EXISTS lazer_user_groups ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL COMMENT '关联 users.id', + group_id INT NOT NULL COMMENT '用户组ID', + group_name VARCHAR(100) NOT NULL COMMENT '用户组名称', + group_identifier VARCHAR(50) NULL COMMENT '用户组标识符', + colour VARCHAR(7) NULL COMMENT '用户组颜色', + is_probationary TINYINT(1) DEFAULT 0 COMMENT '是否为试用期', + has_listing TINYINT(1) DEFAULT 1 COMMENT '是否显示在列表中', + has_playmodes TINYINT(1) DEFAULT 0 COMMENT '是否有游戏模式', + + INDEX idx_user_id (user_id), + INDEX idx_group_id (group_id), + UNIQUE KEY unique_user_group (user_id, group_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户组表'; + +-- 锦标赛横幅表 (active_tournament_banners) +CREATE TABLE IF NOT EXISTS lazer_user_tournament_banners ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL COMMENT '关联 users.id', + tournament_id INT NOT NULL COMMENT '锦标赛ID', + image_url VARCHAR(500) NOT NULL COMMENT '横幅图片URL', + is_active TINYINT(1) DEFAULT 1 COMMENT '是否为当前活跃横幅', + + INDEX idx_user_id (user_id), + INDEX idx_tournament_id (tournament_id), + INDEX idx_is_active (is_active), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户锦标赛横幅表'; + +-- ============================================ +-- 占位表 (未来功能扩展用) +-- ============================================ + +-- 当前赛季统计占位表 +CREATE TABLE IF NOT EXISTS lazer_current_season_stats ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL COMMENT '关联 users.id', + season_id VARCHAR(50) NOT NULL COMMENT '赛季ID', + data_placeholder TEXT COMMENT '赛季数据占位', + + INDEX idx_user_id (user_id), + INDEX idx_season_id (season_id), + UNIQUE KEY unique_user_season (user_id, season_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='当前赛季统计占位表'; + +-- 其他功能占位表 +CREATE TABLE IF NOT EXISTS lazer_feature_placeholder ( + id INT AUTO_INCREMENT PRIMARY KEY, + feature_type VARCHAR(50) NOT NULL COMMENT '功能类型', + entity_id INT NOT NULL COMMENT '实体ID', + data_placeholder TEXT COMMENT '功能数据占位', + + INDEX idx_feature_type (feature_type), + INDEX idx_entity_id (entity_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='功能扩展占位表'; diff --git a/migrations/base.sql b/migrations/base.sql new file mode 100644 index 0000000..79bba0a --- /dev/null +++ b/migrations/base.sql @@ -0,0 +1,486 @@ +create table achievements +( + id int auto_increment + primary key, + file varchar(128) not null, + name varchar(128) charset utf8 not null, + `desc` varchar(256) charset utf8 not null, + cond varchar(64) not null, + constraint achievements_desc_uindex + unique (`desc`), + constraint achievements_file_uindex + unique (file), + constraint achievements_name_uindex + unique (name) +); + +create table channels +( + id int auto_increment + primary key, + name varchar(32) not null, + topic varchar(256) not null, + read_priv int default 1 not null, + write_priv int default 2 not null, + auto_join tinyint(1) default 0 not null, + constraint channels_name_uindex + unique (name) +); +create index channels_auto_join_index + on channels (auto_join); + +create table clans +( + id int auto_increment + primary key, + name varchar(16) charset utf8 not null, + tag varchar(6) charset utf8 not null, + owner int not null, + created_at datetime not null, + constraint clans_name_uindex + unique (name), + constraint clans_owner_uindex + unique (owner), + constraint clans_tag_uindex + unique (tag) +); + +create table client_hashes +( + userid int not null, + osupath char(32) not null, + adapters char(32) not null, + uninstall_id char(32) not null, + disk_serial char(32) not null, + latest_time datetime not null, + occurrences int default 0 not null, + primary key (userid, osupath, adapters, uninstall_id, disk_serial) +); + +create table comments +( + id int auto_increment + primary key, + target_id int not null comment 'replay, map, or set id', + target_type enum('replay', 'map', 'song') not null, + userid int not null, + time int not null, + comment varchar(80) charset utf8 not null, + colour char(6) null comment 'rgb hex string' +); + +create table favourites +( + userid int not null, + setid int not null, + created_at int default 0 not null, + primary key (userid, setid) +); + +create table ingame_logins +( + id int auto_increment + primary key, + userid int not null, + ip varchar(45) not null comment 'maxlen for ipv6', + osu_ver date not null, + osu_stream varchar(11) not null, + datetime datetime not null +); + +create table relationships +( + user1 int not null, + user2 int not null, + type enum('friend', 'block') not null, + primary key (user1, user2) +); + +create table logs +( + id int auto_increment + primary key, + `from` int not null comment 'both from and to are playerids', + `to` int not null, + `action` varchar(32) not null, + msg varchar(2048) charset utf8 null, + time datetime not null on update CURRENT_TIMESTAMP +); + +create table mail +( + id int auto_increment + primary key, + from_id int not null, + to_id int not null, + msg varchar(2048) charset utf8 not null, + time int null, + `read` tinyint(1) default 0 not null +); + +create table maps +( + server enum('osu!', 'private') default 'osu!' not null, + id int not null, + set_id int not null, + status int not null, + md5 char(32) not null, + artist varchar(128) charset utf8 not null, + title varchar(128) charset utf8 not null, + version varchar(128) charset utf8 not null, + creator varchar(19) charset utf8 not null, + filename varchar(256) charset utf8 not null, + last_update datetime not null, + total_length int not null, + max_combo int not null, + frozen tinyint(1) default 0 not null, + plays int default 0 not null, + passes int default 0 not null, + mode tinyint(1) default 0 not null, + bpm float(12,2) default 0.00 not null, + cs float(4,2) default 0.00 not null, + ar float(4,2) default 0.00 not null, + od float(4,2) default 0.00 not null, + hp float(4,2) default 0.00 not null, + diff float(6,3) default 0.000 not null, + primary key (server, id), + constraint maps_id_uindex + unique (id), + constraint maps_md5_uindex + unique (md5) +); +create index maps_set_id_index + on maps (set_id); +create index maps_status_index + on maps (status); +create index maps_filename_index + on maps (filename); +create index maps_plays_index + on maps (plays); +create index maps_mode_index + on maps (mode); +create index maps_frozen_index + on maps (frozen); + +create table mapsets +( + server enum('osu!', 'private') default 'osu!' not null, + id int not null, + last_osuapi_check datetime default CURRENT_TIMESTAMP not null, + primary key (server, id), + constraint nmapsets_id_uindex + unique (id) +); + +create table map_requests +( + id int auto_increment + primary key, + map_id int not null, + player_id int not null, + datetime datetime not null, + active tinyint(1) not null +); + +create table performance_reports +( + scoreid bigint(20) unsigned not null, + mod_mode enum('vanilla', 'relax', 'autopilot') default 'vanilla' not null, + os varchar(64) not null, + fullscreen tinyint(1) not null, + fps_cap varchar(16) not null, + compatibility tinyint(1) not null, + version varchar(16) not null, + start_time int not null, + end_time int not null, + frame_count int not null, + spike_frames int not null, + aim_rate int not null, + completion tinyint(1) not null, + identifier varchar(128) null comment 'really don''t know much about this yet', + average_frametime int not null, + primary key (scoreid, mod_mode) +); + +create table ratings +( + userid int not null, + map_md5 char(32) not null, + rating tinyint(2) not null, + primary key (userid, map_md5) +); + +create table scores +( + id bigint unsigned auto_increment + primary key, + map_md5 char(32) not null, + score int not null, + pp float(7,3) not null, + acc float(6,3) not null, + max_combo int not null, + mods int not null, + n300 int not null, + n100 int not null, + n50 int not null, + nmiss int not null, + ngeki int not null, + nkatu int not null, + grade varchar(2) default 'N' not null, + status tinyint not null, + mode tinyint not null, + play_time datetime not null, + time_elapsed int not null, + client_flags int not null, + userid int not null, + perfect tinyint(1) not null, + online_checksum char(32) not null +); +create index scores_map_md5_index + on scores (map_md5); +create index scores_score_index + on scores (score); +create index scores_pp_index + on scores (pp); +create index scores_mods_index + on scores (mods); +create index scores_status_index + on scores (status); +create index scores_mode_index + on scores (mode); +create index scores_play_time_index + on scores (play_time); +create index scores_userid_index + on scores (userid); +create index scores_online_checksum_index + on scores (online_checksum); +create index scores_fetch_leaderboard_generic_index + on scores (map_md5, status, mode); + +create table startups +( + id int auto_increment + primary key, + ver_major tinyint not null, + ver_minor tinyint not null, + ver_micro tinyint not null, + datetime datetime not null +); + +create table stats +( + id int auto_increment, + mode tinyint(1) not null, + tscore bigint unsigned default 0 not null, + rscore bigint unsigned default 0 not null, + pp int unsigned default 0 not null, + plays int unsigned default 0 not null, + playtime int unsigned default 0 not null, + acc float(6,3) default 0.000 not null, + max_combo int unsigned default 0 not null, + total_hits int unsigned default 0 not null, + replay_views int unsigned default 0 not null, + xh_count int unsigned default 0 not null, + x_count int unsigned default 0 not null, + sh_count int unsigned default 0 not null, + s_count int unsigned default 0 not null, + a_count int unsigned default 0 not null, + primary key (id, mode) +); +create index stats_mode_index + on stats (mode); +create index stats_pp_index + on stats (pp); +create index stats_tscore_index + on stats (tscore); +create index stats_rscore_index + on stats (rscore); + +create table tourney_pool_maps +( + map_id int not null, + pool_id int not null, + mods int not null, + slot tinyint not null, + primary key (map_id, pool_id) +); +create index tourney_pool_maps_mods_slot_index + on tourney_pool_maps (mods, slot); +create index tourney_pool_maps_tourney_pools_id_fk + on tourney_pool_maps (pool_id); + +create table tourney_pools +( + id int auto_increment + primary key, + name varchar(16) not null, + created_at datetime not null, + created_by int not null +); + +create index tourney_pools_users_id_fk + on tourney_pools (created_by); + +create table user_achievements +( + userid int not null, + achid int not null, + primary key (userid, achid) +); +create index user_achievements_achid_index + on user_achievements (achid); +create index user_achievements_userid_index + on user_achievements (userid); + +create table users +( + id int auto_increment + primary key, + name varchar(32) charset utf8 not null, + safe_name varchar(32) charset utf8 not null, + email varchar(254) not null, + priv int default 1 not null, + pw_bcrypt char(60) not null, + country char(2) default 'xx' not null, + silence_end int default 0 not null, + donor_end int default 0 not null, + creation_time int default 0 not null, + latest_activity int default 0 not null, + clan_id int default 0 not null, + clan_priv tinyint(1) default 0 not null, + preferred_mode int default 0 not null, + play_style int default 0 not null, + custom_badge_name varchar(16) charset utf8 null, + custom_badge_icon varchar(64) null, + userpage_content varchar(2048) charset utf8 null, + api_key char(36) null, + constraint users_api_key_uindex + unique (api_key), + constraint users_email_uindex + unique (email), + constraint users_name_uindex + unique (name), + constraint users_safe_name_uindex + unique (safe_name) +); +create index users_priv_index + on users (priv); +create index users_clan_id_index + on users (clan_id); +create index users_clan_priv_index + on users (clan_priv); +create index users_country_index + on users (country); + +insert into users (id, name, safe_name, priv, country, silence_end, email, pw_bcrypt, creation_time, latest_activity) +values (1, 'BanchoBot', 'banchobot', 1, 'ca', 0, 'bot@akatsuki.pw', + '_______________________my_cool_bcrypt_______________________', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()); + +INSERT INTO stats (id, mode) VALUES (1, 0); # vn!std +INSERT INTO stats (id, mode) VALUES (1, 1); # vn!taiko +INSERT INTO stats (id, mode) VALUES (1, 2); # vn!catch +INSERT INTO stats (id, mode) VALUES (1, 3); # vn!mania +INSERT INTO stats (id, mode) VALUES (1, 4); # rx!std +INSERT INTO stats (id, mode) VALUES (1, 5); # rx!taiko +INSERT INTO stats (id, mode) VALUES (1, 6); # rx!catch +INSERT INTO stats (id, mode) VALUES (1, 8); # ap!std + + +# userid 2 is reserved for ppy in osu!, and the +# client will not allow users to pm this id. +# If you want this, simply remove these two lines. +alter table users auto_increment = 3; +alter table stats auto_increment = 3; + +insert into channels (name, topic, read_priv, write_priv, auto_join) +values ('#osu', 'General discussion.', 1, 2, true), + ('#announce', 'Exemplary performance and public announcements.', 1, 24576, true), + ('#lobby', 'Multiplayer lobby discussion room.', 1, 2, false), + ('#supporter', 'General discussion for supporters.', 48, 48, false), + ('#staff', 'General discussion for staff members.', 28672, 28672, true), + ('#admin', 'General discussion for administrators.', 24576, 24576, true), + ('#dev', 'General discussion for developers.', 16384, 16384, true); + +insert into achievements (id, file, name, `desc`, cond) values (1, 'osu-skill-pass-1', 'Rising Star', 'Can''t go forward without the first steps.', '(score.mods & 1 == 0) and 1 <= score.sr < 2 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (2, 'osu-skill-pass-2', 'Constellation Prize', 'Definitely not a consolation prize. Now things start getting hard!', '(score.mods & 1 == 0) and 2 <= score.sr < 3 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (3, 'osu-skill-pass-3', 'Building Confidence', 'Oh, you''ve SO got this.', '(score.mods & 1 == 0) and 3 <= score.sr < 4 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (4, 'osu-skill-pass-4', 'Insanity Approaches', 'You''re not twitching, you''re just ready.', '(score.mods & 1 == 0) and 4 <= score.sr < 5 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (5, 'osu-skill-pass-5', 'These Clarion Skies', 'Everything seems so clear now.', '(score.mods & 1 == 0) and 5 <= score.sr < 6 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (6, 'osu-skill-pass-6', 'Above and Beyond', 'A cut above the rest.', '(score.mods & 1 == 0) and 6 <= score.sr < 7 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (7, 'osu-skill-pass-7', 'Supremacy', 'All marvel before your prowess.', '(score.mods & 1 == 0) and 7 <= score.sr < 8 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (8, 'osu-skill-pass-8', 'Absolution', 'My god, you''re full of stars!', '(score.mods & 1 == 0) and 8 <= score.sr < 9 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (9, 'osu-skill-pass-9', 'Event Horizon', 'No force dares to pull you under.', '(score.mods & 1 == 0) and 9 <= score.sr < 10 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (10, 'osu-skill-pass-10', 'Phantasm', 'Fevered is your passion, extraordinary is your skill.', '(score.mods & 1 == 0) and 10 <= score.sr < 11 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (11, 'osu-skill-fc-1', 'Totality', 'All the notes. Every single one.', 'score.perfect and 1 <= score.sr < 2 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (12, 'osu-skill-fc-2', 'Business As Usual', 'Two to go, please.', 'score.perfect and 2 <= score.sr < 3 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (13, 'osu-skill-fc-3', 'Building Steam', 'Hey, this isn''t so bad.', 'score.perfect and 3 <= score.sr < 4 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (14, 'osu-skill-fc-4', 'Moving Forward', 'Bet you feel good about that.', 'score.perfect and 4 <= score.sr < 5 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (15, 'osu-skill-fc-5', 'Paradigm Shift', 'Surprisingly difficult.', 'score.perfect and 5 <= score.sr < 6 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (16, 'osu-skill-fc-6', 'Anguish Quelled', 'Don''t choke.', 'score.perfect and 6 <= score.sr < 7 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (17, 'osu-skill-fc-7', 'Never Give Up', 'Excellence is its own reward.', 'score.perfect and 7 <= score.sr < 8 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (18, 'osu-skill-fc-8', 'Aberration', 'They said it couldn''t be done. They were wrong.', 'score.perfect and 8 <= score.sr < 9 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (19, 'osu-skill-fc-9', 'Chosen', 'Reign among the Prometheans, where you belong.', 'score.perfect and 9 <= score.sr < 10 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (20, 'osu-skill-fc-10', 'Unfathomable', 'You have no equal.', 'score.perfect and 10 <= score.sr < 11 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (21, 'osu-combo-500', '500 Combo', '500 big ones! You''re moving up in the world!', '500 <= score.max_combo < 750 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (22, 'osu-combo-750', '750 Combo', '750 notes back to back? Woah.', '750 <= score.max_combo < 1000 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (23, 'osu-combo-1000', '1000 Combo', 'A thousand reasons why you rock at this game.', '1000 <= score.max_combo < 2000 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (24, 'osu-combo-2000', '2000 Combo', 'Nothing can stop you now.', '2000 <= score.max_combo and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (25, 'taiko-skill-pass-1', 'My First Don', 'Marching to the beat of your own drum. Literally.', '(score.mods & 1 == 0) and 1 <= score.sr < 2 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (26, 'taiko-skill-pass-2', 'Katsu Katsu Katsu', 'Hora! Izuko!', '(score.mods & 1 == 0) and 2 <= score.sr < 3 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (27, 'taiko-skill-pass-3', 'Not Even Trying', 'Muzukashii? Not even.', '(score.mods & 1 == 0) and 3 <= score.sr < 4 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (28, 'taiko-skill-pass-4', 'Face Your Demons', 'The first trials are now behind you, but are you a match for the Oni?', '(score.mods & 1 == 0) and 4 <= score.sr < 5 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (29, 'taiko-skill-pass-5', 'The Demon Within', 'No rest for the wicked.', '(score.mods & 1 == 0) and 5 <= score.sr < 6 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (30, 'taiko-skill-pass-6', 'Drumbreaker', 'Too strong.', '(score.mods & 1 == 0) and 6 <= score.sr < 7 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (31, 'taiko-skill-pass-7', 'The Godfather', 'You are the Don of Dons.', '(score.mods & 1 == 0) and 7 <= score.sr < 8 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (32, 'taiko-skill-pass-8', 'Rhythm Incarnate', 'Feel the beat. Become the beat.', '(score.mods & 1 == 0) and 8 <= score.sr < 9 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (33, 'taiko-skill-fc-1', 'Keeping Time', 'Don, then katsu. Don, then katsu..', 'score.perfect and 1 <= score.sr < 2 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (34, 'taiko-skill-fc-2', 'To Your Own Beat', 'Straight and steady.', 'score.perfect and 2 <= score.sr < 3 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (35, 'taiko-skill-fc-3', 'Big Drums', 'Bigger scores to match.', 'score.perfect and 3 <= score.sr < 4 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (36, 'taiko-skill-fc-4', 'Adversity Overcome', 'Difficult? Not for you.', 'score.perfect and 4 <= score.sr < 5 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (37, 'taiko-skill-fc-5', 'Demonslayer', 'An Oni felled forevermore.', 'score.perfect and 5 <= score.sr < 6 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (38, 'taiko-skill-fc-6', 'Rhythm''s Call', 'Heralding true skill.', 'score.perfect and 6 <= score.sr < 7 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (39, 'taiko-skill-fc-7', 'Time Everlasting', 'Not a single beat escapes you.', 'score.perfect and 7 <= score.sr < 8 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (40, 'taiko-skill-fc-8', 'The Drummer''s Throne', 'Percussive brilliance befitting royalty alone.', 'score.perfect and 8 <= score.sr < 9 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (41, 'fruits-skill-pass-1', 'A Slice Of Life', 'Hey, this fruit catching business isn''t bad.', '(score.mods & 1 == 0) and 1 <= score.sr < 2 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (42, 'fruits-skill-pass-2', 'Dashing Ever Forward', 'Fast is how you do it.', '(score.mods & 1 == 0) and 2 <= score.sr < 3 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (43, 'fruits-skill-pass-3', 'Zesty Disposition', 'No scurvy for you, not with that much fruit.', '(score.mods & 1 == 0) and 3 <= score.sr < 4 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (44, 'fruits-skill-pass-4', 'Hyperdash ON!', 'Time and distance is no obstacle to you.', '(score.mods & 1 == 0) and 4 <= score.sr < 5 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (45, 'fruits-skill-pass-5', 'It''s Raining Fruit', 'And you can catch them all.', '(score.mods & 1 == 0) and 5 <= score.sr < 6 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (46, 'fruits-skill-pass-6', 'Fruit Ninja', 'Legendary techniques.', '(score.mods & 1 == 0) and 6 <= score.sr < 7 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (47, 'fruits-skill-pass-7', 'Dreamcatcher', 'No fruit, only dreams now.', '(score.mods & 1 == 0) and 7 <= score.sr < 8 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (48, 'fruits-skill-pass-8', 'Lord of the Catch', 'Your kingdom kneels before you.', '(score.mods & 1 == 0) and 8 <= score.sr < 9 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (49, 'fruits-skill-fc-1', 'Sweet And Sour', 'Apples and oranges, literally.', 'score.perfect and 1 <= score.sr < 2 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (50, 'fruits-skill-fc-2', 'Reaching The Core', 'The seeds of future success.', 'score.perfect and 2 <= score.sr < 3 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (51, 'fruits-skill-fc-3', 'Clean Platter', 'Clean only of failure. It is completely full, otherwise.', 'score.perfect and 3 <= score.sr < 4 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (52, 'fruits-skill-fc-4', 'Between The Rain', 'No umbrella needed.', 'score.perfect and 4 <= score.sr < 5 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (53, 'fruits-skill-fc-5', 'Addicted', 'That was an overdose?', 'score.perfect and 5 <= score.sr < 6 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (54, 'fruits-skill-fc-6', 'Quickening', 'A dash above normal limits.', 'score.perfect and 6 <= score.sr < 7 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (55, 'fruits-skill-fc-7', 'Supersonic', 'Faster than is reasonably necessary.', 'score.perfect and 7 <= score.sr < 8 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (56, 'fruits-skill-fc-8', 'Dashing Scarlet', 'Speed beyond mortal reckoning.', 'score.perfect and 8 <= score.sr < 9 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (57, 'mania-skill-pass-1', 'First Steps', 'It isn''t 9-to-5, but 1-to-9. Keys, that is.', '(score.mods & 1 == 0) and 1 <= score.sr < 2 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (58, 'mania-skill-pass-2', 'No Normal Player', 'Not anymore, at least.', '(score.mods & 1 == 0) and 2 <= score.sr < 3 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (59, 'mania-skill-pass-3', 'Impulse Drive', 'Not quite hyperspeed, but getting close.', '(score.mods & 1 == 0) and 3 <= score.sr < 4 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (60, 'mania-skill-pass-4', 'Hyperspeed', 'Woah.', '(score.mods & 1 == 0) and 4 <= score.sr < 5 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (61, 'mania-skill-pass-5', 'Ever Onwards', 'Another challenge is just around the corner.', '(score.mods & 1 == 0) and 5 <= score.sr < 6 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (62, 'mania-skill-pass-6', 'Another Surpassed', 'Is there no limit to your skills?', '(score.mods & 1 == 0) and 6 <= score.sr < 7 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (63, 'mania-skill-pass-7', 'Extra Credit', 'See me after class.', '(score.mods & 1 == 0) and 7 <= score.sr < 8 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (64, 'mania-skill-pass-8', 'Maniac', 'There''s just no stopping you.', '(score.mods & 1 == 0) and 8 <= score.sr < 9 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (65, 'mania-skill-fc-1', 'Keystruck', 'The beginning of a new story', 'score.perfect and 1 <= score.sr < 2 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (66, 'mania-skill-fc-2', 'Keying In', 'Finding your groove.', 'score.perfect and 2 <= score.sr < 3 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (67, 'mania-skill-fc-3', 'Hyperflow', 'You can *feel* the rhythm.', 'score.perfect and 3 <= score.sr < 4 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (68, 'mania-skill-fc-4', 'Breakthrough', 'Many skills mastered, rolled into one.', 'score.perfect and 4 <= score.sr < 5 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (69, 'mania-skill-fc-5', 'Everything Extra', 'Giving your all is giving everything you have.', 'score.perfect and 5 <= score.sr < 6 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (70, 'mania-skill-fc-6', 'Level Breaker', 'Finesse beyond reason', 'score.perfect and 6 <= score.sr < 7 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (71, 'mania-skill-fc-7', 'Step Up', 'A precipice rarely seen.', 'score.perfect and 7 <= score.sr < 8 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (72, 'mania-skill-fc-8', 'Behind The Veil', 'Supernatural!', 'score.perfect and 8 <= score.sr < 9 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (73, 'all-intro-suddendeath', 'Finality', 'High stakes, no regrets.', 'score.mods == 32'); +insert into achievements (id, file, name, `desc`, cond) values (74, 'all-intro-hidden', 'Blindsight', 'I can see just perfectly', 'score.mods & 8'); +insert into achievements (id, file, name, `desc`, cond) values (75, 'all-intro-perfect', 'Perfectionist', 'Accept nothing but the best.', 'score.mods & 16384'); +insert into achievements (id, file, name, `desc`, cond) values (76, 'all-intro-hardrock', 'Rock Around The Clock', "You can\'t stop the rock.", 'score.mods & 16'); +insert into achievements (id, file, name, `desc`, cond) values (77, 'all-intro-doubletime', 'Time And A Half', "Having a right ol\' time. One and a half of them, almost.", 'score.mods & 64'); +insert into achievements (id, file, name, `desc`, cond) values (78, 'all-intro-flashlight', 'Are You Afraid Of The Dark?', "Harder than it looks, probably because it\'s hard to look.", 'score.mods & 1024'); +insert into achievements (id, file, name, `desc`, cond) values (79, 'all-intro-easy', 'Dial It Right Back', 'Sometimes you just want to take it easy.', 'score.mods & 2'); +insert into achievements (id, file, name, `desc`, cond) values (80, 'all-intro-nofail', 'Risk Averse', 'Safety nets are fun!', 'score.mods & 1'); +insert into achievements (id, file, name, `desc`, cond) values (81, 'all-intro-nightcore', 'Sweet Rave Party', 'Founded in the fine tradition of changing things that were just fine as they were.', 'score.mods & 512'); +insert into achievements (id, file, name, `desc`, cond) values (82, 'all-intro-halftime', 'Slowboat', 'You got there. Eventually.', 'score.mods & 256'); +insert into achievements (id, file, name, `desc`, cond) values (83, 'all-intro-spunout', 'Burned Out', 'One cannot always spin to win.', 'score.mods & 4096'); diff --git a/migrations/custom_beatmaps.sql b/migrations/custom_beatmaps.sql new file mode 100644 index 0000000..9148845 --- /dev/null +++ b/migrations/custom_beatmaps.sql @@ -0,0 +1,209 @@ +-- 自定义谱面系统迁移 +-- 创建自定义谱面表,与官方谱面不冲突 + +-- 自定义谱面集表 +CREATE TABLE custom_mapsets ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + creator_id INT NOT NULL, + title VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + artist VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + source VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '', + tags TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + description TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + status ENUM('pending', 'approved', 'rejected', 'loved') DEFAULT 'pending', + upload_date DATETIME DEFAULT CURRENT_TIMESTAMP, + last_update DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + osz_filename VARCHAR(255) NOT NULL, + osz_hash CHAR(32) NOT NULL, + download_count INT DEFAULT 0, + favourite_count INT DEFAULT 0, + UNIQUE KEY idx_custom_mapsets_id (id), + KEY idx_custom_mapsets_creator (creator_id), + KEY idx_custom_mapsets_status (status), + KEY idx_custom_mapsets_upload_date (upload_date), + UNIQUE KEY idx_custom_mapsets_osz_hash (osz_hash) +); + +-- 自定义谱面难度表 +CREATE TABLE custom_maps ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + mapset_id BIGINT NOT NULL, + md5 CHAR(32) NOT NULL, + difficulty_name VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + filename VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + mode TINYINT DEFAULT 0 NOT NULL COMMENT '0=osu!, 1=taiko, 2=catch, 3=mania', + status ENUM('pending', 'approved', 'rejected', 'loved') DEFAULT 'pending', + + -- osu!文件基本信息 + audio_filename VARCHAR(255) DEFAULT '', + audio_lead_in INT DEFAULT 0, + preview_time INT DEFAULT -1, + countdown TINYINT DEFAULT 1, + sample_set VARCHAR(16) DEFAULT 'Normal', + stack_leniency DECIMAL(3,2) DEFAULT 0.70, + letterbox_in_breaks BOOLEAN DEFAULT FALSE, + story_fire_in_front BOOLEAN DEFAULT TRUE, + use_skin_sprites BOOLEAN DEFAULT FALSE, + always_show_playfield BOOLEAN DEFAULT FALSE, + overlay_position VARCHAR(16) DEFAULT 'NoChange', + skin_preference VARCHAR(255) DEFAULT '', + epilepsy_warning BOOLEAN DEFAULT FALSE, + countdown_offset INT DEFAULT 0, + special_style BOOLEAN DEFAULT FALSE, + widescreen_storyboard BOOLEAN DEFAULT FALSE, + samples_match_playback_rate BOOLEAN DEFAULT FALSE, + + -- 编辑器信息 + distance_spacing DECIMAL(6,3) DEFAULT 1.000, + beat_divisor TINYINT DEFAULT 4, + grid_size TINYINT DEFAULT 4, + timeline_zoom DECIMAL(6,3) DEFAULT 1.000, + + -- 谱面元数据 + title_unicode VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '', + artist_unicode VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '', + creator VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + version VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + source VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '', + tags TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + beatmap_id BIGINT DEFAULT 0, + beatmapset_id BIGINT DEFAULT 0, + + -- 难度设定 + hp_drain_rate DECIMAL(3,1) DEFAULT 5.0, + circle_size DECIMAL(3,1) DEFAULT 5.0, + overall_difficulty DECIMAL(3,1) DEFAULT 5.0, + approach_rate DECIMAL(3,1) DEFAULT 5.0, + slider_multiplier DECIMAL(6,3) DEFAULT 1.400, + slider_tick_rate DECIMAL(3,1) DEFAULT 1.0, + + -- 计算得出的信息 + total_length INT DEFAULT 0 COMMENT '总长度(秒)', + hit_length INT DEFAULT 0 COMMENT '击打长度(秒)', + max_combo INT DEFAULT 0, + bpm DECIMAL(8,3) DEFAULT 0.000, + star_rating DECIMAL(6,3) DEFAULT 0.000, + aim_difficulty DECIMAL(6,3) DEFAULT 0.000, + speed_difficulty DECIMAL(6,3) DEFAULT 0.000, + + -- 统计信息 + plays INT DEFAULT 0, + passes INT DEFAULT 0, + + -- 时间戳 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY idx_custom_maps_id (id), + UNIQUE KEY idx_custom_maps_md5 (md5), + KEY idx_custom_maps_mapset (mapset_id), + KEY idx_custom_maps_mode (mode), + KEY idx_custom_maps_status (status), + KEY idx_custom_maps_creator (creator), + KEY idx_custom_maps_star_rating (star_rating), + KEY idx_custom_maps_plays (plays), + + FOREIGN KEY (mapset_id) REFERENCES custom_mapsets(id) ON DELETE CASCADE +); + +-- 自定义谱面书签表 +CREATE TABLE custom_map_bookmarks ( + user_id INT NOT NULL, + mapset_id BIGINT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, mapset_id), + FOREIGN KEY (mapset_id) REFERENCES custom_mapsets(id) ON DELETE CASCADE +); + +-- 自定义谱面评分表 +CREATE TABLE custom_map_ratings ( + user_id INT NOT NULL, + map_id BIGINT NOT NULL, + rating TINYINT NOT NULL CHECK (rating >= 1 AND rating <= 10), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, map_id), + FOREIGN KEY (map_id) REFERENCES custom_maps(id) ON DELETE CASCADE +); + +-- 自定义谱面成绩表 (继承原scores表结构) +CREATE TABLE custom_scores ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + map_id BIGINT NOT NULL, + map_md5 CHAR(32) NOT NULL, + user_id INT NOT NULL, + score INT NOT NULL, + pp FLOAT(7,3) NOT NULL, + acc FLOAT(6,3) NOT NULL, + max_combo INT NOT NULL, + mods INT NOT NULL, + n300 INT NOT NULL, + n100 INT NOT NULL, + n50 INT NOT NULL, + nmiss INT NOT NULL, + ngeki INT NOT NULL, + nkatu INT NOT NULL, + grade VARCHAR(2) DEFAULT 'N' NOT NULL, + status TINYINT NOT NULL COMMENT '0=failed, 1=submitted, 2=best', + mode TINYINT NOT NULL, + play_time DATETIME NOT NULL, + time_elapsed INT NOT NULL, + client_flags INT NOT NULL, + perfect BOOLEAN NOT NULL, + online_checksum CHAR(32) NOT NULL, + + KEY idx_custom_scores_map_id (map_id), + KEY idx_custom_scores_map_md5 (map_md5), + KEY idx_custom_scores_user_id (user_id), + KEY idx_custom_scores_score (score), + KEY idx_custom_scores_pp (pp), + KEY idx_custom_scores_mods (mods), + KEY idx_custom_scores_status (status), + KEY idx_custom_scores_mode (mode), + KEY idx_custom_scores_play_time (play_time), + KEY idx_custom_scores_online_checksum (online_checksum), + KEY idx_custom_scores_leaderboard (map_md5, status, mode), + + FOREIGN KEY (map_id) REFERENCES custom_maps(id) ON DELETE CASCADE +); + +-- 自定义谱面文件存储表 (用于存储.osu文件内容等) +CREATE TABLE custom_map_files ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + map_id BIGINT NOT NULL, + file_type ENUM('osu', 'audio', 'image', 'video', 'storyboard') NOT NULL, + filename VARCHAR(255) NOT NULL, + file_hash CHAR(32) NOT NULL, + file_size INT NOT NULL, + mime_type VARCHAR(100) DEFAULT '', + storage_path VARCHAR(500) NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + UNIQUE KEY idx_custom_map_files_id (id), + KEY idx_custom_map_files_map_id (map_id), + KEY idx_custom_map_files_type (file_type), + KEY idx_custom_map_files_hash (file_hash), + + FOREIGN KEY (map_id) REFERENCES custom_maps(id) ON DELETE CASCADE +); + +-- 为自定义谱面创建专门的ID生成器,避免与官方ID冲突 +-- 自定义谱面ID从1000000开始 +ALTER TABLE custom_mapsets AUTO_INCREMENT = 3000000; +ALTER TABLE custom_maps AUTO_INCREMENT = 3000000; + +-- 创建触发器来同步mapset信息到maps表 +DELIMITER $$ + +CREATE TRIGGER update_custom_mapset_on_map_change +AFTER UPDATE ON custom_maps +FOR EACH ROW +BEGIN + IF NEW.status != OLD.status THEN + UPDATE custom_mapsets + SET last_update = CURRENT_TIMESTAMP + WHERE id = NEW.mapset_id; + END IF; +END$$ + +DELIMITER ; diff --git a/migrations/migrations.sql b/migrations/migrations.sql new file mode 100644 index 0000000..95794b6 --- /dev/null +++ b/migrations/migrations.sql @@ -0,0 +1,477 @@ +# This file contains any sql updates, along with the +# version they are required from. Touching this without +# at least reading utils/updater.py is certainly a bad idea :) + +# v3.0.6 +alter table users change name_safe safe_name varchar(32) not null; +alter table users drop key users_name_safe_uindex; +alter table users add constraint users_safe_name_uindex unique (safe_name); +alter table users change pw_hash pw_bcrypt char(60) not null; +insert into channels (name, topic, read_priv, write_priv, auto_join) values + ('#supporter', 'General discussion for p2w gamers.', 48, 48, false), + ('#staff', 'General discussion for the cool kids.', 28672, 28672, true), + ('#admin', 'General discussion for the cool.', 24576, 24576, true), + ('#dev', 'General discussion for the.', 16384, 16384, true); + +# v3.0.8 +alter table users modify safe_name varchar(32) charset utf8 not null; +alter table users modify name varchar(32) charset utf8 not null; +alter table mail modify msg varchar(2048) charset utf8 not null; +alter table logs modify msg varchar(2048) charset utf8 not null; +drop table if exists comments; +create table comments +( + id int auto_increment + primary key, + target_id int not null comment 'replay, map, or set id', + target_type enum('replay', 'map', 'song') not null, + userid int not null, + time int not null, + comment varchar(80) charset utf8 not null, + colour char(6) null comment 'rgb hex string' +); + +# v3.0.9 +alter table stats modify tscore_vn_std int unsigned default 0 not null; +alter table stats modify tscore_vn_taiko int unsigned default 0 not null; +alter table stats modify tscore_vn_catch int unsigned default 0 not null; +alter table stats modify tscore_vn_mania int unsigned default 0 not null; +alter table stats modify tscore_rx_std int unsigned default 0 not null; +alter table stats modify tscore_rx_taiko int unsigned default 0 not null; +alter table stats modify tscore_rx_catch int unsigned default 0 not null; +alter table stats modify tscore_ap_std int unsigned default 0 not null; +alter table stats modify rscore_vn_std int unsigned default 0 not null; +alter table stats modify rscore_vn_taiko int unsigned default 0 not null; +alter table stats modify rscore_vn_catch int unsigned default 0 not null; +alter table stats modify rscore_vn_mania int unsigned default 0 not null; +alter table stats modify rscore_rx_std int unsigned default 0 not null; +alter table stats modify rscore_rx_taiko int unsigned default 0 not null; +alter table stats modify rscore_rx_catch int unsigned default 0 not null; +alter table stats modify rscore_ap_std int unsigned default 0 not null; +alter table stats modify pp_vn_std smallint unsigned default 0 not null; +alter table stats modify pp_vn_taiko smallint unsigned default 0 not null; +alter table stats modify pp_vn_catch smallint unsigned default 0 not null; +alter table stats modify pp_vn_mania smallint unsigned default 0 not null; +alter table stats modify pp_rx_std smallint unsigned default 0 not null; +alter table stats modify pp_rx_taiko smallint unsigned default 0 not null; +alter table stats modify pp_rx_catch smallint unsigned default 0 not null; +alter table stats modify pp_ap_std smallint unsigned default 0 not null; +alter table stats modify plays_vn_std int unsigned default 0 not null; +alter table stats modify plays_vn_taiko int unsigned default 0 not null; +alter table stats modify plays_vn_catch int unsigned default 0 not null; +alter table stats modify plays_vn_mania int unsigned default 0 not null; +alter table stats modify plays_rx_std int unsigned default 0 not null; +alter table stats modify plays_rx_taiko int unsigned default 0 not null; +alter table stats modify plays_rx_catch int unsigned default 0 not null; +alter table stats modify plays_ap_std int unsigned default 0 not null; +alter table stats modify playtime_vn_std int unsigned default 0 not null; +alter table stats modify playtime_vn_taiko int unsigned default 0 not null; +alter table stats modify playtime_vn_catch int unsigned default 0 not null; +alter table stats modify playtime_vn_mania int unsigned default 0 not null; +alter table stats modify playtime_rx_std int unsigned default 0 not null; +alter table stats modify playtime_rx_taiko int unsigned default 0 not null; +alter table stats modify playtime_rx_catch int unsigned default 0 not null; +alter table stats modify playtime_ap_std int unsigned default 0 not null; +alter table stats modify maxcombo_vn_std int unsigned default 0 not null; +alter table stats modify maxcombo_vn_taiko int unsigned default 0 not null; +alter table stats modify maxcombo_vn_catch int unsigned default 0 not null; +alter table stats modify maxcombo_vn_mania int unsigned default 0 not null; +alter table stats modify maxcombo_rx_std int unsigned default 0 not null; +alter table stats modify maxcombo_rx_taiko int unsigned default 0 not null; +alter table stats modify maxcombo_rx_catch int unsigned default 0 not null; +alter table stats modify maxcombo_ap_std int unsigned default 0 not null; + +# v3.0.10 +update channels set write_priv = 24576 where name = '#announce'; + +# v3.1.0 +alter table maps modify bpm float(12,2) default 0.00 not null; +alter table stats modify tscore_vn_std bigint unsigned default 0 not null; +alter table stats modify tscore_vn_taiko bigint unsigned default 0 not null; +alter table stats modify tscore_vn_catch bigint unsigned default 0 not null; +alter table stats modify tscore_vn_mania bigint unsigned default 0 not null; +alter table stats modify tscore_rx_std bigint unsigned default 0 not null; +alter table stats modify tscore_rx_taiko bigint unsigned default 0 not null; +alter table stats modify tscore_rx_catch bigint unsigned default 0 not null; +alter table stats modify tscore_ap_std bigint unsigned default 0 not null; +alter table stats modify rscore_vn_std bigint unsigned default 0 not null; +alter table stats modify rscore_vn_taiko bigint unsigned default 0 not null; +alter table stats modify rscore_vn_catch bigint unsigned default 0 not null; +alter table stats modify rscore_vn_mania bigint unsigned default 0 not null; +alter table stats modify rscore_rx_std bigint unsigned default 0 not null; +alter table stats modify rscore_rx_taiko bigint unsigned default 0 not null; +alter table stats modify rscore_rx_catch bigint unsigned default 0 not null; +alter table stats modify rscore_ap_std bigint unsigned default 0 not null; +alter table stats modify pp_vn_std int unsigned default 0 not null; +alter table stats modify pp_vn_taiko int unsigned default 0 not null; +alter table stats modify pp_vn_catch int unsigned default 0 not null; +alter table stats modify pp_vn_mania int unsigned default 0 not null; +alter table stats modify pp_rx_std int unsigned default 0 not null; +alter table stats modify pp_rx_taiko int unsigned default 0 not null; +alter table stats modify pp_rx_catch int unsigned default 0 not null; +alter table stats modify pp_ap_std int unsigned default 0 not null; + +# v3.1.2 +create table clans +( + id int auto_increment + primary key, + name varchar(16) not null, + tag varchar(6) not null, + owner int not null, + created_at datetime not null, + constraint clans_name_uindex + unique (name), + constraint clans_owner_uindex + unique (owner), + constraint clans_tag_uindex + unique (tag) +); +alter table users add clan_id int default 0 not null; +alter table users add clan_rank tinyint(1) default 0 not null; +create table achievements +( + id int auto_increment + primary key, + file varchar(128) not null, + name varchar(128) not null, + `desc` varchar(256) not null, + cond varchar(64) not null, + mode tinyint(1) not null, + constraint achievements_desc_uindex + unique (`desc`), + constraint achievements_file_uindex + unique (file), + constraint achievements_name_uindex + unique (name) +); +create table user_achievements +( + userid int not null, + achid int not null, + primary key (userid, achid) +); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (1, 'osu-skill-pass-1', 'Rising Star', 'Can''t go forward without the first steps.', '(score.mods & 259 == 0) and 2 >= score.sr > 1', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (2, 'osu-skill-pass-2', 'Constellation Prize', 'Definitely not a consolation prize. Now things start getting hard!', '(score.mods & 259 == 0) and 3 >= score.sr > 2', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (3, 'osu-skill-pass-3', 'Building Confidence', 'Oh, you''ve SO got this.', '(score.mods & 259 == 0) and 4 >= score.sr > 3', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (4, 'osu-skill-pass-4', 'Insanity Approaches', 'You''re not twitching, you''re just ready.', '(score.mods & 259 == 0) and 5 >= score.sr > 4', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (5, 'osu-skill-pass-5', 'These Clarion Skies', 'Everything seems so clear now.', '(score.mods & 259 == 0) and 6 >= score.sr > 5', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (6, 'osu-skill-pass-6', 'Above and Beyond', 'A cut above the rest.', '(score.mods & 259 == 0) and 7 >= score.sr > 6', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (7, 'osu-skill-pass-7', 'Supremacy', 'All marvel before your prowess.', '(score.mods & 259 == 0) and 8 >= score.sr > 7', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (8, 'osu-skill-pass-8', 'Absolution', 'My god, you''re full of stars!', '(score.mods & 259 == 0) and 9 >= score.sr > 8', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (9, 'osu-skill-pass-9', 'Event Horizon', 'No force dares to pull you under.', '(score.mods & 259 == 0) and 10 >= score.sr > 9', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (10, 'osu-skill-pass-10', 'Phantasm', 'Fevered is your passion, extraordinary is your skill.', '(score.mods & 259 == 0) and 11 >= score.sr > 10', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (11, 'osu-skill-fc-1', 'Totality', 'All the notes. Every single one.', 'score.perfect and 2 >= score.sr > 1', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (12, 'osu-skill-fc-2', 'Business As Usual', 'Two to go, please.', 'score.perfect and 3 >= score.sr > 2', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (13, 'osu-skill-fc-3', 'Building Steam', 'Hey, this isn''t so bad.', 'score.perfect and 4 >= score.sr > 3', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (14, 'osu-skill-fc-4', 'Moving Forward', 'Bet you feel good about that.', 'score.perfect and 5 >= score.sr > 4', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (15, 'osu-skill-fc-5', 'Paradigm Shift', 'Surprisingly difficult.', 'score.perfect and 6 >= score.sr > 5', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (16, 'osu-skill-fc-6', 'Anguish Quelled', 'Don''t choke.', 'score.perfect and 7 >= score.sr > 6', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (17, 'osu-skill-fc-7', 'Never Give Up', 'Excellence is its own reward.', 'score.perfect and 8 >= score.sr > 7', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (18, 'osu-skill-fc-8', 'Aberration', 'They said it couldn''t be done. They were wrong.', 'score.perfect and 9 >= score.sr > 8', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (19, 'osu-skill-fc-9', 'Chosen', 'Reign among the Prometheans, where you belong.', 'score.perfect and 10 >= score.sr > 9', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (20, 'osu-skill-fc-10', 'Unfathomable', 'You have no equal.', 'score.perfect and 11 >= score.sr > 10', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (21, 'osu-combo-500', '500 Combo', '500 big ones! You''re moving up in the world!', '750 >= score.max_combo > 500', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (22, 'osu-combo-750', '750 Combo', '750 notes back to back? Woah.', '1000 >= score.max_combo > 750', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (23, 'osu-combo-1000', '1000 Combo', 'A thousand reasons why you rock at this game.', '2000 >= score.max_combo > 1000', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (24, 'osu-combo-2000', '2000 Combo', 'Nothing can stop you now.', 'score.max_combo >= 2000', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (25, 'taiko-skill-pass-1', 'My First Don', 'Marching to the beat of your own drum. Literally.', '(score.mods & 259 == 0) and 2 >= score.sr > 1', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (26, 'taiko-skill-pass-2', 'Katsu Katsu Katsu', 'Hora! Izuko!', '(score.mods & 259 == 0) and 3 >= score.sr > 2', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (27, 'taiko-skill-pass-3', 'Not Even Trying', 'Muzukashii? Not even.', '(score.mods & 259 == 0) and 4 >= score.sr > 3', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (28, 'taiko-skill-pass-4', 'Face Your Demons', 'The first trials are now behind you, but are you a match for the Oni?', '(score.mods & 259 == 0) and 5 >= score.sr > 4', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (29, 'taiko-skill-pass-5', 'The Demon Within', 'No rest for the wicked.', '(score.mods & 259 == 0) and 6 >= score.sr > 5', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (30, 'taiko-skill-pass-6', 'Drumbreaker', 'Too strong.', '(score.mods & 259 == 0) and 7 >= score.sr > 6', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (31, 'taiko-skill-pass-7', 'The Godfather', 'You are the Don of Dons.', '(score.mods & 259 == 0) and 8 >= score.sr > 7', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (32, 'taiko-skill-pass-8', 'Rhythm Incarnate', 'Feel the beat. Become the beat.', '(score.mods & 259 == 0) and 9 >= score.sr > 8', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (33, 'taiko-skill-fc-1', 'Keeping Time', 'Don, then katsu. Don, then katsu..', 'score.perfect and 2 >= score.sr > 1', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (34, 'taiko-skill-fc-2', 'To Your Own Beat', 'Straight and steady.', 'score.perfect and 3 >= score.sr > 2', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (35, 'taiko-skill-fc-3', 'Big Drums', 'Bigger scores to match.', 'score.perfect and 4 >= score.sr > 3', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (36, 'taiko-skill-fc-4', 'Adversity Overcome', 'Difficult? Not for you.', 'score.perfect and 5 >= score.sr > 4', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (37, 'taiko-skill-fc-5', 'Demonslayer', 'An Oni felled forevermore.', 'score.perfect and 6 >= score.sr > 5', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (38, 'taiko-skill-fc-6', 'Rhythm''s Call', 'Heralding true skill.', 'score.perfect and 7 >= score.sr > 6', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (39, 'taiko-skill-fc-7', 'Time Everlasting', 'Not a single beat escapes you.', 'score.perfect and 8 >= score.sr > 7', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (40, 'taiko-skill-fc-8', 'The Drummer''s Throne', 'Percussive brilliance befitting royalty alone.', 'score.perfect and 9 >= score.sr > 8', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (41, 'fruits-skill-pass-1', 'A Slice Of Life', 'Hey, this fruit catching business isn''t bad.', '(score.mods & 259 == 0) and 2 >= score.sr > 1', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (42, 'fruits-skill-pass-2', 'Dashing Ever Forward', 'Fast is how you do it.', '(score.mods & 259 == 0) and 3 >= score.sr > 2', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (43, 'fruits-skill-pass-3', 'Zesty Disposition', 'No scurvy for you, not with that much fruit.', '(score.mods & 259 == 0) and 4 >= score.sr > 3', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (44, 'fruits-skill-pass-4', 'Hyperdash ON!', 'Time and distance is no obstacle to you.', '(score.mods & 259 == 0) and 5 >= score.sr > 4', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (45, 'fruits-skill-pass-5', 'It''s Raining Fruit', 'And you can catch them all.', '(score.mods & 259 == 0) and 6 >= score.sr > 5', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (46, 'fruits-skill-pass-6', 'Fruit Ninja', 'Legendary techniques.', '(score.mods & 259 == 0) and 7 >= score.sr > 6', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (47, 'fruits-skill-pass-7', 'Dreamcatcher', 'No fruit, only dreams now.', '(score.mods & 259 == 0) and 8 >= score.sr > 7', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (48, 'fruits-skill-pass-8', 'Lord of the Catch', 'Your kingdom kneels before you.', '(score.mods & 259 == 0) and 9 >= score.sr > 8', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (49, 'fruits-skill-fc-1', 'Sweet And Sour', 'Apples and oranges, literally.', 'score.perfect and 2 >= score.sr > 1', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (50, 'fruits-skill-fc-2', 'Reaching The Core', 'The seeds of future success.', 'score.perfect and 3 >= score.sr > 2', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (51, 'fruits-skill-fc-3', 'Clean Platter', 'Clean only of failure. It is completely full, otherwise.', 'score.perfect and 4 >= score.sr > 3', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (52, 'fruits-skill-fc-4', 'Between The Rain', 'No umbrella needed.', 'score.perfect and 5 >= score.sr > 4', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (53, 'fruits-skill-fc-5', 'Addicted', 'That was an overdose?', 'score.perfect and 6 >= score.sr > 5', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (54, 'fruits-skill-fc-6', 'Quickening', 'A dash above normal limits.', 'score.perfect and 7 >= score.sr > 6', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (55, 'fruits-skill-fc-7', 'Supersonic', 'Faster than is reasonably necessary.', 'score.perfect and 8 >= score.sr > 7', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (56, 'fruits-skill-fc-8', 'Dashing Scarlet', 'Speed beyond mortal reckoning.', 'score.perfect and 9 >= score.sr > 8', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (57, 'mania-skill-pass-1', 'First Steps', 'It isn''t 9-to-5, but 1-to-9. Keys, that is.', '(score.mods & 259 == 0) and 2 >= score.sr > 1', 3); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (58, 'mania-skill-pass-2', 'No Normal Player', 'Not anymore, at least.', '(score.mods & 259 == 0) and 3 >= score.sr > 2', 3); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (59, 'mania-skill-pass-3', 'Impulse Drive', 'Not quite hyperspeed, but getting close.', '(score.mods & 259 == 0) and 4 >= score.sr > 3', 3); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (60, 'mania-skill-pass-4', 'Hyperspeed', 'Woah.', '(score.mods & 259 == 0) and 5 >= score.sr > 4', 3); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (61, 'mania-skill-pass-5', 'Ever Onwards', 'Another challenge is just around the corner.', '(score.mods & 259 == 0) and 6 >= score.sr > 5', 3); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (62, 'mania-skill-pass-6', 'Another Surpassed', 'Is there no limit to your skills?', '(score.mods & 259 == 0) and 7 >= score.sr > 6', 3); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (63, 'mania-skill-pass-7', 'Extra Credit', 'See me after class.', '(score.mods & 259 == 0) and 8 >= score.sr > 7', 3); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (64, 'mania-skill-pass-8', 'Maniac', 'There''s just no stopping you.', '(score.mods & 259 == 0) and 9 >= score.sr > 8', 3); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (65, 'mania-skill-fc-1', 'Keystruck', 'The beginning of a new story', 'score.perfect and (score.mods & 259 == 0) and 2 >= score.sr > 1', 3); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (66, 'mania-skill-fc-2', 'Keying In', 'Finding your groove.', 'score.perfect and 3 >= score.sr > 2', 3); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (67, 'mania-skill-fc-3', 'Hyperflow', 'You can *feel* the rhythm.', 'score.perfect and 4 >= score.sr > 3', 3); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (68, 'mania-skill-fc-4', 'Breakthrough', 'Many skills mastered, rolled into one.', 'score.perfect and 5 >= score.sr > 4', 3); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (69, 'mania-skill-fc-5', 'Everything Extra', 'Giving your all is giving everything you have.', 'score.perfect and 6 >= score.sr > 5', 3); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (70, 'mania-skill-fc-6', 'Level Breaker', 'Finesse beyond reason', 'score.perfect and 7 >= score.sr > 6', 3); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (71, 'mania-skill-fc-7', 'Step Up', 'A precipice rarely seen.', 'score.perfect and 8 >= score.sr > 7', 3); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (72, 'mania-skill-fc-8', 'Behind The Veil', 'Supernatural!', 'score.perfect and 9 >= score.sr > 8', 3); + +# v3.1.3 +alter table clans modify name varchar(16) charset utf8 not null; +alter table clans modify tag varchar(6) charset utf8 not null; +alter table achievements modify name varchar(128) charset utf8 not null; +alter table achievements modify `desc` varchar(256) charset utf8 not null; +alter table maps modify artist varchar(128) charset utf8 not null; +alter table maps modify title varchar(128) charset utf8 not null; +alter table maps modify version varchar(128) charset utf8 not null; +alter table maps modify creator varchar(19) charset utf8 not null comment 'not 100%% certain on len'; +alter table tourney_pools drop foreign key tourney_pools_users_id_fk; +alter table tourney_pool_maps drop foreign key tourney_pool_maps_tourney_pools_id_fk; +alter table stats drop foreign key stats_users_id_fk; +alter table ratings drop foreign key ratings_maps_md5_fk; +alter table ratings drop foreign key ratings_users_id_fk; +alter table logs modify `from` int not null comment 'both from and to are playerids'; + +# v3.1.9 +alter table scores_rx modify id bigint(20) unsigned auto_increment; +update scores_rx set id = id + (6148914691236517205 - 1); +select @max_rx := MAX(id) + 1 from scores_rx; +set @s = CONCAT('alter table scores_rx auto_increment = ', @max_rx); +prepare stmt from @s; +execute stmt; +deallocate PREPARE stmt; +alter table scores_ap modify id bigint(20) unsigned auto_increment; +update scores_ap set id = id + (12297829382473034410 - 1); +select @max_ap := MAX(id) + 1 from scores_ap; +set @s = CONCAT('alter table scores_ap auto_increment = ', @max_ap); +prepare stmt from @s; +execute stmt; +deallocate PREPARE stmt; +alter table performance_reports modify scoreid bigint(20) unsigned auto_increment; + +# v3.2.0 +create table map_requests +( + id int auto_increment + primary key, + map_id int not null, + player_id int not null, + datetime datetime not null, + active tinyint(1) not null +); + +# v3.2.1 +update scores_rx set id = id - 3074457345618258603; +update scores_ap set id = id - 6148914691236517206; + +# v3.2.2 +alter table maps add max_combo int not null after total_length; +alter table users change clan_rank clan_priv tinyint(1) default 0 not null; + +# v3.2.3 +alter table users add api_key char(36) default NULL null; +create unique index users_api_key_uindex on users (api_key); + +# v3.2.4 +update achievements set file = replace(file, 'ctb', 'fruits') where mode = 2; + +# v3.2.5 +update achievements set cond = '(score.mods & 1 == 0) and 1 <= score.sr < 2' where file in ('osu-skill-pass-1', 'taiko-skill-pass-1', 'fruits-skill-pass-1', 'mania-skill-pass-1'); +update achievements set cond = '(score.mods & 1 == 0) and 2 <= score.sr < 3' where file in ('osu-skill-pass-2', 'taiko-skill-pass-2', 'fruits-skill-pass-2', 'mania-skill-pass-2'); +update achievements set cond = '(score.mods & 1 == 0) and 3 <= score.sr < 4' where file in ('osu-skill-pass-3', 'taiko-skill-pass-3', 'fruits-skill-pass-3', 'mania-skill-pass-3'); +update achievements set cond = '(score.mods & 1 == 0) and 4 <= score.sr < 5' where file in ('osu-skill-pass-4', 'taiko-skill-pass-4', 'fruits-skill-pass-4', 'mania-skill-pass-4'); +update achievements set cond = '(score.mods & 1 == 0) and 5 <= score.sr < 6' where file in ('osu-skill-pass-5', 'taiko-skill-pass-5', 'fruits-skill-pass-5', 'mania-skill-pass-5'); +update achievements set cond = '(score.mods & 1 == 0) and 6 <= score.sr < 7' where file in ('osu-skill-pass-6', 'taiko-skill-pass-6', 'fruits-skill-pass-6', 'mania-skill-pass-6'); +update achievements set cond = '(score.mods & 1 == 0) and 7 <= score.sr < 8' where file in ('osu-skill-pass-7', 'taiko-skill-pass-7', 'fruits-skill-pass-7', 'mania-skill-pass-7'); +update achievements set cond = '(score.mods & 1 == 0) and 8 <= score.sr < 9' where file in ('osu-skill-pass-8', 'taiko-skill-pass-8', 'fruits-skill-pass-8', 'mania-skill-pass-8'); +update achievements set cond = '(score.mods & 1 == 0) and 9 <= score.sr < 10' where file = 'osu-skill-pass-9'; +update achievements set cond = '(score.mods & 1 == 0) and 10 <= score.sr < 11' where file = 'osu-skill-pass-10'; + +update achievements set cond = 'score.perfect and 1 <= score.sr < 2' where file in ('osu-skill-fc-1', 'taiko-skill-fc-1', 'fruits-skill-fc-1', 'mania-skill-fc-1'); +update achievements set cond = 'score.perfect and 2 <= score.sr < 3' where file in ('osu-skill-fc-2', 'taiko-skill-fc-2', 'fruits-skill-fc-2', 'mania-skill-fc-2'); +update achievements set cond = 'score.perfect and 3 <= score.sr < 4' where file in ('osu-skill-fc-3', 'taiko-skill-fc-3', 'fruits-skill-fc-3', 'mania-skill-fc-3'); +update achievements set cond = 'score.perfect and 4 <= score.sr < 5' where file in ('osu-skill-fc-4', 'taiko-skill-fc-4', 'fruits-skill-fc-4', 'mania-skill-fc-4'); +update achievements set cond = 'score.perfect and 5 <= score.sr < 6' where file in ('osu-skill-fc-5', 'taiko-skill-fc-5', 'fruits-skill-fc-5', 'mania-skill-fc-5'); +update achievements set cond = 'score.perfect and 6 <= score.sr < 7' where file in ('osu-skill-fc-6', 'taiko-skill-fc-6', 'fruits-skill-fc-6', 'mania-skill-fc-6'); +update achievements set cond = 'score.perfect and 7 <= score.sr < 8' where file in ('osu-skill-fc-7', 'taiko-skill-fc-7', 'fruits-skill-fc-7', 'mania-skill-fc-7'); +update achievements set cond = 'score.perfect and 8 <= score.sr < 9' where file in ('osu-skill-fc-8', 'taiko-skill-fc-8', 'fruits-skill-fc-8', 'mania-skill-fc-8'); +update achievements set cond = 'score.perfect and 9 <= score.sr < 10' where file = 'osu-skill-fc-9'; +update achievements set cond = 'score.perfect and 10 <= score.sr < 11' where file = 'osu-skill-fc-10'; + +update achievements set cond = '500 <= score.max_combo < 750' where file = 'osu-combo-500'; +update achievements set cond = '750 <= score.max_combo < 1000' where file = 'osu-combo-750'; +update achievements set cond = '1000 <= score.max_combo < 2000' where file = 'osu-combo-1000'; +update achievements set cond = '2000 <= score.max_combo' where file = 'osu-combo-2000'; + +# v3.2.6 +alter table stats change maxcombo_vn_std max_combo_vn_std int unsigned default 0 not null; +alter table stats change maxcombo_vn_taiko max_combo_vn_taiko int unsigned default 0 not null; +alter table stats change maxcombo_vn_catch max_combo_vn_catch int unsigned default 0 not null; +alter table stats change maxcombo_vn_mania max_combo_vn_mania int unsigned default 0 not null; +alter table stats change maxcombo_rx_std max_combo_rx_std int unsigned default 0 not null; +alter table stats change maxcombo_rx_taiko max_combo_rx_taiko int unsigned default 0 not null; +alter table stats change maxcombo_rx_catch max_combo_rx_catch int unsigned default 0 not null; +alter table stats change maxcombo_ap_std max_combo_ap_std int unsigned default 0 not null; + +# v3.2.7 +drop table if exists user_hashes; + +# v3.3.0 +rename table friendships to relationships; +alter table relationships add type enum('friend', 'block') not null; + +# v3.3.1 +create table ingame_logins +( + id int auto_increment + primary key, + userid int not null, + ip varchar(45) not null comment 'maxlen for ipv6', + osu_ver date not null, + osu_stream varchar(11) not null, + datetime datetime not null +); + +# v3.3.7 +update achievements set cond = CONCAT(cond, ' and mode_vn == 0') where mode = 0; +update achievements set cond = CONCAT(cond, ' and mode_vn == 1') where mode = 1; +update achievements set cond = CONCAT(cond, ' and mode_vn == 2') where mode = 2; +update achievements set cond = CONCAT(cond, ' and mode_vn == 3') where mode = 3; +alter table achievements drop column mode; + +# v3.3.8 +create table mapsets +( + server enum('osu!', 'gulag') default 'osu!' not null, + id int not null, + last_osuapi_check datetime default CURRENT_TIMESTAMP not null, + primary key (server, id), + constraint nmapsets_id_uindex + unique (id) +); + +# v3.4.1 +alter table maps add filename varchar(256) charset utf8 not null after creator; + +# v3.5.2 +alter table scores_vn add online_checksum char(32) not null; +alter table scores_rx add online_checksum char(32) not null; +alter table scores_ap add online_checksum char(32) not null; + +# v4.1.1 +alter table stats add total_hits int unsigned default 0 not null after max_combo; + +# v4.1.2 +alter table stats add replay_views int unsigned default 0 not null after total_hits; + +# v4.1.3 +alter table users add preferred_mode int default 0 not null after latest_activity; +alter table users add play_style int default 0 not null after preferred_mode; +alter table users add custom_badge_name varchar(16) charset utf8 null after play_style; +alter table users add custom_badge_icon varchar(64) null after custom_badge_name; +alter table users add userpage_content varchar(2048) charset utf8 null after custom_badge_icon; + +# v4.2.0 +# please refer to tools/migrate_v420 for further v4.2.0 migrations +update stats set mode = 8 where mode = 7; + +# v4.3.1 +alter table maps change server server enum('osu!', 'private') default 'osu!' not null; +alter table mapsets change server server enum('osu!', 'private') default 'osu!' not null; + +# v4.4.2 +insert into achievements (id, file, name, `desc`, cond) values (73, 'all-intro-suddendeath', 'Finality', 'High stakes, no regrets.', 'score.mods == 32'); +insert into achievements (id, file, name, `desc`, cond) values (74, 'all-intro-hidden', 'Blindsight', 'I can see just perfectly', 'score.mods & 8'); +insert into achievements (id, file, name, `desc`, cond) values (75, 'all-intro-perfect', 'Perfectionist', 'Accept nothing but the best.', 'score.mods & 16384'); +insert into achievements (id, file, name, `desc`, cond) values (76, 'all-intro-hardrock', 'Rock Around The Clock', "You can\'t stop the rock.", 'score.mods & 16'); +insert into achievements (id, file, name, `desc`, cond) values (77, 'all-intro-doubletime', 'Time And A Half', "Having a right ol\' time. One and a half of them, almost.", 'score.mods & 64'); +insert into achievements (id, file, name, `desc`, cond) values (78, 'all-intro-flashlight', 'Are You Afraid Of The Dark?', "Harder than it looks, probably because it\'s hard to look.", 'score.mods & 1024'); +insert into achievements (id, file, name, `desc`, cond) values (79, 'all-intro-easy', 'Dial It Right Back', 'Sometimes you just want to take it easy.', 'score.mods & 2'); +insert into achievements (id, file, name, `desc`, cond) values (80, 'all-intro-nofail', 'Risk Averse', 'Safety nets are fun!', 'score.mods & 1'); +insert into achievements (id, file, name, `desc`, cond) values (81, 'all-intro-nightcore', 'Sweet Rave Party', 'Founded in the fine tradition of changing things that were just fine as they were.', 'score.mods & 512'); +insert into achievements (id, file, name, `desc`, cond) values (82, 'all-intro-halftime', 'Slowboat', 'You got there. Eventually.', 'score.mods & 256'); +insert into achievements (id, file, name, `desc`, cond) values (83, 'all-intro-spunout', 'Burned Out', 'One cannot always spin to win.', 'score.mods & 4096'); + +# v4.4.3 +alter table favourites add created_at int default 0 not null; + +# v4.7.1 +lock tables maps write; +alter table maps drop primary key; +alter table maps add primary key (id); +alter table maps modify column server enum('osu!', 'private') not null default 'osu!' after id; +unlock tables; + +# v5.0.1 +create index channels_auto_join_index + on channels (auto_join); + +create index maps_set_id_index + on maps (set_id); +create index maps_status_index + on maps (status); +create index maps_filename_index + on maps (filename); +create index maps_plays_index + on maps (plays); +create index maps_mode_index + on maps (mode); +create index maps_frozen_index + on maps (frozen); + +create index scores_map_md5_index + on scores (map_md5); +create index scores_score_index + on scores (score); +create index scores_pp_index + on scores (pp); +create index scores_mods_index + on scores (mods); +create index scores_status_index + on scores (status); +create index scores_mode_index + on scores (mode); +create index scores_play_time_index + on scores (play_time); +create index scores_userid_index + on scores (userid); +create index scores_online_checksum_index + on scores (online_checksum); + +create index stats_mode_index + on stats (mode); +create index stats_pp_index + on stats (pp); +create index stats_tscore_index + on stats (tscore); +create index stats_rscore_index + on stats (rscore); + +create index tourney_pool_maps_mods_slot_index + on tourney_pool_maps (mods, slot); + +create index user_achievements_achid_index + on user_achievements (achid); +create index user_achievements_userid_index + on user_achievements (userid); + +create index users_priv_index + on users (priv); +create index users_clan_id_index + on users (clan_id); +create index users_clan_priv_index + on users (clan_priv); +create index users_country_index + on users (country); + +# v5.2.2 +create index scores_fetch_leaderboard_generic_index + on scores (map_md5, status, mode); diff --git a/migrations/sync_legacy_data.sql b/migrations/sync_legacy_data.sql new file mode 100644 index 0000000..19c94a4 --- /dev/null +++ b/migrations/sync_legacy_data.sql @@ -0,0 +1,337 @@ +-- Lazer API 数据同步脚本 +-- 从现有的 bancho.py 表结构同步数据到新的 lazer 专用表 +-- 执行此脚本前请确保已执行 add_missing_fields.sql + +-- ============================================ +-- 同步用户基本资料数据 +-- ============================================ + +-- 同步用户扩展资料 +INSERT INTO lazer_user_profiles ( + user_id, + is_active, + is_bot, + is_deleted, + is_online, + is_supporter, + is_restricted, + session_verified, + has_supported, + pm_friends_only, + default_group, + last_visit, + join_date, + profile_colour, + profile_hue, + avatar_url, + cover_url, + discord, + twitter, + website, + title, + title_url, + interests, + location, + occupation, + playmode, + support_level, + max_blocks, + max_friends, + post_count, + page_html, + page_raw +) +SELECT + u.id as user_id, + -- 基本状态字段 (使用默认值,因为原表没有这些字段) + 1 as is_active, + CASE WHEN u.name = 'BanchoBot' THEN 1 ELSE 0 END as is_bot, + 0 as is_deleted, + 1 as is_online, + CASE WHEN u.donor_end > UNIX_TIMESTAMP() THEN 1 ELSE 0 END as is_supporter, + CASE WHEN (u.priv & 1) = 0 THEN 1 ELSE 0 END as is_restricted, + 0 as session_verified, + CASE WHEN u.donor_end > 0 THEN 1 ELSE 0 END as has_supported, + 0 as pm_friends_only, + + -- 基本资料字段 + 'default' as default_group, + CASE WHEN u.latest_activity > 0 THEN FROM_UNIXTIME(u.latest_activity) ELSE NULL END as last_visit, + CASE WHEN u.creation_time > 0 THEN FROM_UNIXTIME(u.creation_time) ELSE NULL END as join_date, + NULL as profile_colour, + NULL as profile_hue, + + -- 社交媒体和个人资料字段 (使用默认值) + CONCAT('https://a.ppy.sh/', u.id) as avatar_url, + CONCAT('https://assets.ppy.sh/user-profile-covers/banners/', u.id, '.jpg') as cover_url, + NULL as discord, + NULL as twitter, + NULL as website, + u.custom_badge_name as title, + NULL as title_url, + NULL as interests, + CASE WHEN u.country != 'xx' THEN u.country ELSE NULL END as location, + NULL as occupation, + + -- 游戏相关字段 + CASE u.preferred_mode + WHEN 0 THEN 'osu' + WHEN 1 THEN 'taiko' + WHEN 2 THEN 'fruits' + WHEN 3 THEN 'mania' + ELSE 'osu' + END as playmode, + CASE WHEN u.donor_end > UNIX_TIMESTAMP() THEN 1 ELSE 0 END as support_level, + 100 as max_blocks, + 500 as max_friends, + 0 as post_count, + + -- 页面内容 + u.userpage_content as page_html, + u.userpage_content as page_raw + +FROM users u +ON DUPLICATE KEY UPDATE + last_visit = VALUES(last_visit), + join_date = VALUES(join_date), + is_supporter = VALUES(is_supporter), + is_restricted = VALUES(is_restricted), + has_supported = VALUES(has_supported), + title = VALUES(title), + location = VALUES(location), + playmode = VALUES(playmode), + support_level = VALUES(support_level), + page_html = VALUES(page_html), + page_raw = VALUES(page_raw); + +-- 同步用户国家信息 +INSERT INTO lazer_user_countries ( + user_id, + code, + name +) +SELECT + u.id as user_id, + UPPER(u.country) as code, + CASE UPPER(u.country) + WHEN 'CN' THEN 'China' + WHEN 'US' THEN 'United States' + WHEN 'JP' THEN 'Japan' + WHEN 'KR' THEN 'South Korea' + WHEN 'CA' THEN 'Canada' + WHEN 'GB' THEN 'United Kingdom' + WHEN 'DE' THEN 'Germany' + WHEN 'FR' THEN 'France' + WHEN 'AU' THEN 'Australia' + WHEN 'RU' THEN 'Russia' + ELSE 'Unknown' + END as name +FROM users u +WHERE u.country IS NOT NULL AND u.country != 'xx' +ON DUPLICATE KEY UPDATE + code = VALUES(code), + name = VALUES(name); + +-- 同步用户 Kudosu (使用默认值) +INSERT INTO lazer_user_kudosu ( + user_id, + available, + total +) +SELECT + u.id as user_id, + 0 as available, + 0 as total +FROM users u +ON DUPLICATE KEY UPDATE + available = VALUES(available), + total = VALUES(total); + +-- 同步用户统计计数 (使用默认值) +INSERT INTO lazer_user_counts ( + user_id, + beatmap_playcounts_count, + comments_count, + favourite_beatmapset_count, + follower_count, + graveyard_beatmapset_count, + guest_beatmapset_count, + loved_beatmapset_count, + mapping_follower_count, + nominated_beatmapset_count, + pending_beatmapset_count, + ranked_beatmapset_count, + ranked_and_approved_beatmapset_count, + unranked_beatmapset_count, + scores_best_count, + scores_first_count, + scores_pinned_count, + scores_recent_count +) +SELECT + u.id as user_id, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +FROM users u +ON DUPLICATE KEY UPDATE + user_id = VALUES(user_id); + +-- ============================================ +-- 同步游戏统计数据 +-- ============================================ + +-- 从 stats 表同步用户统计数据到 lazer_user_statistics +INSERT INTO lazer_user_statistics ( + user_id, + mode, + count_100, + count_300, + count_50, + count_miss, + level_current, + level_progress, + global_rank, + country_rank, + pp, + ranked_score, + hit_accuracy, + total_score, + total_hits, + maximum_combo, + play_count, + play_time, + replays_watched_by_others, + is_ranked, + grade_ss, + grade_ssh, + grade_s, + grade_sh, + grade_a +) +SELECT + s.id as user_id, + CASE s.mode + WHEN 0 THEN 'osu' + WHEN 1 THEN 'taiko' + WHEN 2 THEN 'fruits' + WHEN 3 THEN 'mania' + ELSE 'osu' + END as mode, + + -- 基本命中统计 + s.n100 as count_100, + s.n300 as count_300, + s.n50 as count_50, + s.nmiss as count_miss, + + -- 等级信息 + 1 as level_current, + 0 as level_progress, + + -- 排名信息 + NULL as global_rank, + NULL as country_rank, + + -- PP 和分数 + s.pp as pp, + s.rscore as ranked_score, + CASE WHEN (s.n300 + s.n100 + s.n50 + s.nmiss) > 0 + THEN ROUND((s.n300 * 300 + s.n100 * 100 + s.n50 * 50) / ((s.n300 + s.n100 + s.n50 + s.nmiss) * 300) * 100, 2) + ELSE 0.00 + END as hit_accuracy, + s.tscore as total_score, + (s.n300 + s.n100 + s.n50) as total_hits, + s.max_combo as maximum_combo, + + -- 游戏统计 + s.plays as play_count, + s.playtime as play_time, + 0 as replays_watched_by_others, + CASE WHEN s.pp > 0 THEN 1 ELSE 0 END as is_ranked, + + -- 成绩等级计数 + 0 as grade_ss, + 0 as grade_ssh, + 0 as grade_s, + 0 as grade_sh, + 0 as grade_a + +FROM stats s +WHERE EXISTS (SELECT 1 FROM users u WHERE u.id = s.id) +ON DUPLICATE KEY UPDATE + count_100 = VALUES(count_100), + count_300 = VALUES(count_300), + count_50 = VALUES(count_50), + count_miss = VALUES(count_miss), + pp = VALUES(pp), + ranked_score = VALUES(ranked_score), + hit_accuracy = VALUES(hit_accuracy), + total_score = VALUES(total_score), + total_hits = VALUES(total_hits), + maximum_combo = VALUES(maximum_combo), + play_count = VALUES(play_count), + play_time = VALUES(play_time), + is_ranked = VALUES(is_ranked); + +-- ============================================ +-- 同步用户成就数据 +-- ============================================ + +-- 从 user_achievements 表同步数据(如果存在的话) +INSERT IGNORE INTO lazer_user_achievements ( + user_id, + achievement_id, + achieved_at +) +SELECT + ua.userid as user_id, + ua.achid as achievement_id, + NOW() as achieved_at -- 使用当前时间作为获得时间 +FROM user_achievements ua +WHERE EXISTS (SELECT 1 FROM users u WHERE u.id = ua.userid); + +-- ============================================ +-- 创建基础 OAuth 令牌记录(如果需要的话) +-- ============================================ + +-- 注意: OAuth 令牌通常在用户登录时动态创建,这里不需要预先填充 + +-- ============================================ +-- 同步完成提示 +-- ============================================ + +-- 显示同步统计信息 +SELECT + 'lazer_user_profiles' as table_name, + COUNT(*) as synced_records +FROM lazer_user_profiles +UNION ALL +SELECT + 'lazer_user_countries' as table_name, + COUNT(*) as synced_records +FROM lazer_user_countries +UNION ALL +SELECT + 'lazer_user_statistics' as table_name, + COUNT(*) as synced_records +FROM lazer_user_statistics +UNION ALL +SELECT + 'lazer_user_achievements' as table_name, + COUNT(*) as synced_records +FROM lazer_user_achievements; + +-- 显示一些样本数据 +SELECT + u.id, + u.name, + lup.is_supporter, + lup.playmode, + luc.code as country_code, + lus.pp, + lus.play_count +FROM users u +LEFT JOIN lazer_user_profiles lup ON u.id = lup.user_id +LEFT JOIN lazer_user_countries luc ON u.id = luc.user_id +LEFT JOIN lazer_user_statistics lus ON u.id = lus.user_id AND lus.mode = 'osu' +ORDER BY u.id +LIMIT 10; diff --git a/osu_api_example.py b/osu_api_example.py new file mode 100644 index 0000000..c07e776 --- /dev/null +++ b/osu_api_example.py @@ -0,0 +1,59 @@ +import requests +import os + +CLIENT_ID = os.environ.get('OSU_CLIENT_ID', '5') +CLIENT_SECRET = os.environ.get('OSU_CLIENT_SECRET', 'FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk') +API_URL = os.environ.get('OSU_API_URL', 'https://osu.ppy.sh') + + +def authenticate(username: str, password: str): + """Authenticate via OAuth password flow and return the token dict.""" + url = f"{API_URL}/oauth/token" + data = { + "grant_type": "password", + "username": username, + "password": password, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "scope": "*", + } + response = requests.post(url, data=data) + response.raise_for_status() + return response.json() + + +def refresh_token(refresh: str): + """Refresh the OAuth token.""" + url = f"{API_URL}/oauth/token" + data = { + "grant_type": "refresh_token", + "refresh_token": refresh, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "scope": "*", + } + response = requests.post(url, data=data) + response.raise_for_status() + return response.json() + + +def get_current_user(access_token: str, ruleset: str = "osu"): + """Retrieve the authenticated user's data.""" + url = f"{API_URL}/api/v2/me/{ruleset}" + headers = {"Authorization": f"Bearer {access_token}"} + response = requests.get(url, headers=headers) + response.raise_for_status() + return response.json() + + +if __name__ == "__main__": + import getpass + + username = input("osu! username: ") + password = getpass.getpass() + + token = authenticate(username, password) + print("Access Token:", token["access_token"]) + user = get_current_user(token["access_token"]) + + print(user) \ No newline at end of file diff --git a/quick_sync.py b/quick_sync.py new file mode 100644 index 0000000..4a0ed17 --- /dev/null +++ b/quick_sync.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +""" +简化的数据同步执行脚本 +直接使用项目配置执行数据同步 +""" + +import os +import sys +import subprocess +from urllib.parse import urlparse +from app.config import settings + +def parse_database_url(): + """解析数据库 URL""" + url = urlparse(settings.DATABASE_URL) + return { + 'host': url.hostname or 'localhost', + 'port': url.port or 3306, + 'user': url.username or 'root', + 'password': url.password or '', + 'database': url.path.lstrip('/') if url.path else 'osu_api' + } + +def run_sql_script(script_path: str): + """使用 mysql 命令行执行 SQL 脚本""" + if not os.path.exists(script_path): + print(f"错误: SQL 脚本不存在 - {script_path}") + return False + + # 解析数据库配置 + db_config = parse_database_url() + + # 构建 mysql 命令 + cmd = [ + 'mysql', + f'--host={db_config["host"]}', + f'--port={db_config["port"]}', + f'--user={db_config["user"]}', + db_config['database'] + ] + + # 添加密码(如果有的话) + if db_config['password']: + cmd.insert(-1, f'--password={db_config["password"]}') + + try: + print(f"执行 SQL 脚本: {script_path}") + with open(script_path, 'r', encoding='utf-8') as f: + result = subprocess.run( + cmd, + stdin=f, + capture_output=True, + text=True, + check=True + ) + + if result.stdout: + print("执行结果:") + print(result.stdout) + + print(f"✓ 成功执行: {script_path}") + return True + + except subprocess.CalledProcessError as e: + print(f"✗ 执行失败: {script_path}") + print(f"错误信息: {e.stderr}") + return False + except FileNotFoundError: + print("错误: 未找到 mysql 命令行工具") + print("请确保 MySQL 客户端已安装并添加到 PATH 环境变量中") + return False + +def main(): + """主函数""" + print("Lazer API 快速数据同步") + print("=" * 40) + + db_config = parse_database_url() + print(f"数据库: {db_config['host']}:{db_config['port']}/{db_config['database']}") + print() + + # 确认是否继续 + print("这将执行以下操作:") + print("1. 创建 lazer 专用表结构") + print("2. 同步现有用户数据到新表") + print("3. 不会修改现有的原始表数据") + print() + + confirm = input("是否继续? (y/N): ").strip().lower() + if confirm != 'y': + print("操作已取消") + return + + # 获取脚本路径 + script_dir = os.path.dirname(__file__) + migrations_dir = os.path.join(script_dir, 'migrations') + + # 第一步: 创建表结构 + print("\n步骤 1: 创建 lazer 专用表结构...") + add_fields_script = os.path.join(migrations_dir, 'add_missing_fields.sql') + if not run_sql_script(add_fields_script): + print("表结构创建失败,停止执行") + return + + # 第二步: 同步数据 + print("\n步骤 2: 同步历史数据...") + sync_script = os.path.join(migrations_dir, 'sync_legacy_data.sql') + if not run_sql_script(sync_script): + print("数据同步失败") + return + + # 第三步: 添加缺失的字段 + print("\n步骤 3: 添加缺失的字段...") + add_rank_fields_script = os.path.join(migrations_dir, 'add_lazer_rank_fields.sql') + if not run_sql_script(add_rank_fields_script): + print("添加字段失败") + return + + print("\n🎉 数据同步完成!") + print("\n现在您可以:") + print("1. 启动 Lazer API 服务器") + print("2. 使用现有用户账号登录") + print("3. 查看同步后的用户数据") + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2aa8b01 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +sqlalchemy==2.0.23 +alembic==1.12.1 +pymysql==1.1.0 +cryptography==41.0.7 +redis==5.0.1 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.6 +pydantic[email]==2.5.0 +python-dotenv==1.0.0 +bcrypt==4.1.2 diff --git a/sync_data.py b/sync_data.py new file mode 100644 index 0000000..7408e99 --- /dev/null +++ b/sync_data.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +""" +Lazer API 数据同步脚本 +用于将现有的 bancho.py 数据同步到新的 lazer 专用表中 +""" + +import os +import sys +import pymysql +from typing import Optional +import logging +from datetime import datetime + +# 配置日志 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('data_sync.log'), + logging.StreamHandler(sys.stdout) + ] +) + +logger = logging.getLogger(__name__) + +class DatabaseSyncer: + def __init__(self, host: str, port: int, user: str, password: str, database: str): + """初始化数据库连接配置""" + self.host = host + self.port = port + self.user = user + self.password = password + self.database = database + self.connection = None + + def connect(self): + """连接到数据库""" + try: + self.connection = pymysql.connect( + host=self.host, + port=self.port, + user=self.user, + password=self.password, + database=self.database, + charset='utf8mb4', + autocommit=False + ) + logger.info(f"成功连接到数据库 {self.database}") + except Exception as e: + logger.error(f"连接数据库失败: {e}") + raise + + def disconnect(self): + """断开数据库连接""" + if self.connection: + self.connection.close() + logger.info("数据库连接已关闭") + + def execute_sql_file(self, file_path: str): + """执行 SQL 文件""" + if not os.path.exists(file_path): + logger.error(f"SQL 文件不存在: {file_path}") + return False + + try: + with open(file_path, 'r', encoding='utf-8') as f: + sql_content = f.read() + + # 分割SQL语句(简单实现,按分号分割) + statements = [stmt.strip() for stmt in sql_content.split(';') if stmt.strip()] + + cursor = self.connection.cursor() + + for i, statement in enumerate(statements): + # 跳过注释和空语句 + if statement.startswith('--') or not statement: + continue + + try: + logger.info(f"执行第 {i+1}/{len(statements)} 条SQL语句...") + cursor.execute(statement) + + # 如果是SELECT语句,显示结果 + if statement.strip().upper().startswith('SELECT'): + results = cursor.fetchall() + if results: + logger.info(f"查询结果: {results}") + + except Exception as e: + logger.error(f"执行SQL语句失败: {statement[:100]}...") + logger.error(f"错误信息: {e}") + # 继续执行其他语句 + continue + + self.connection.commit() + cursor.close() + logger.info(f"成功执行SQL文件: {file_path}") + return True + + except Exception as e: + logger.error(f"执行SQL文件失败: {e}") + if self.connection: + self.connection.rollback() + return False + + def check_tables_exist(self, tables: list) -> dict: + """检查表是否存在""" + results = {} + cursor = self.connection.cursor() + + for table in tables: + try: + cursor.execute(f"SHOW TABLES LIKE '{table}'") + exists = cursor.fetchone() is not None + results[table] = exists + logger.info(f"表 '{table}' {'存在' if exists else '不存在'}") + except Exception as e: + logger.error(f"检查表 '{table}' 时出错: {e}") + results[table] = False + + cursor.close() + return results + + def get_table_count(self, table: str) -> int: + """获取表的记录数""" + try: + cursor = self.connection.cursor() + cursor.execute(f"SELECT COUNT(*) FROM {table}") + result = cursor.fetchone() + count = result[0] if result else 0 + cursor.close() + return count + except Exception as e: + logger.error(f"获取表 '{table}' 记录数失败: {e}") + return -1 + +def main(): + """主函数""" + print("Lazer API 数据同步工具") + print("=" * 50) + + # 数据库配置 + db_config = { + 'host': input("数据库主机 [localhost]: ").strip() or 'localhost', + 'port': int(input("数据库端口 [3306]: ").strip() or '3306'), + 'user': input("数据库用户名: ").strip(), + 'password': input("数据库密码: ").strip(), + 'database': input("数据库名称: ").strip() + } + + syncer = DatabaseSyncer(**db_config) + + try: + # 连接数据库 + syncer.connect() + + # 检查必要的原始表是否存在 + required_tables = ['users', 'stats'] + table_status = syncer.check_tables_exist(required_tables) + + missing_tables = [table for table, exists in table_status.items() if not exists] + if missing_tables: + logger.error(f"缺少必要的原始表: {missing_tables}") + return + + # 显示原始表的记录数 + for table in required_tables: + count = syncer.get_table_count(table) + logger.info(f"表 '{table}' 当前有 {count} 条记录") + + # 确认是否执行同步 + print("\n准备执行数据同步...") + print("这将会:") + print("1. 创建 lazer 专用表结构 (如果不存在)") + print("2. 从现有表同步数据到新表") + print("3. 不会修改或删除现有数据") + + confirm = input("\n是否继续? (y/N): ").strip().lower() + if confirm != 'y': + print("操作已取消") + return + + # 执行表结构创建 + migrations_dir = os.path.join(os.path.dirname(__file__), 'migrations') + + print("\n步骤 1: 创建表结构...") + add_fields_sql = os.path.join(migrations_dir, 'add_missing_fields.sql') + if os.path.exists(add_fields_sql): + success = syncer.execute_sql_file(add_fields_sql) + if not success: + logger.error("创建表结构失败") + return + else: + logger.warning(f"表结构文件不存在: {add_fields_sql}") + + # 执行数据同步 + print("\n步骤 2: 同步数据...") + sync_sql = os.path.join(migrations_dir, 'sync_legacy_data.sql') + if os.path.exists(sync_sql): + success = syncer.execute_sql_file(sync_sql) + if not success: + logger.error("数据同步失败") + return + else: + logger.error(f"同步脚本不存在: {sync_sql}") + return + + # 显示同步后的统计信息 + print("\n步骤 3: 同步完成统计...") + lazer_tables = [ + 'lazer_user_profiles', + 'lazer_user_countries', + 'lazer_user_statistics', + 'lazer_user_kudosu', + 'lazer_user_counts' + ] + + for table in lazer_tables: + count = syncer.get_table_count(table) + if count >= 0: + logger.info(f"表 '{table}' 现在有 {count} 条记录") + + print("\n数据同步完成!") + + except KeyboardInterrupt: + print("\n\n操作被用户中断") + except Exception as e: + logger.error(f"同步过程中发生错误: {e}") + finally: + syncer.disconnect() + +if __name__ == "__main__": + main() diff --git a/test_api.py b/test_api.py new file mode 100644 index 0000000..2249af9 --- /dev/null +++ b/test_api.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +""" +测试 osu! API 模拟服务器的脚本 +""" + +import requests +import os +from dotenv import load_dotenv +import json + +# 加载 .env 文件 +load_dotenv() + +CLIENT_ID = os.environ.get('OSU_CLIENT_ID', '5') +CLIENT_SECRET = os.environ.get('OSU_CLIENT_SECRET', 'FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk') +API_URL = os.environ.get('OSU_API_URL', 'http://localhost:8000') + +def test_server_health(): + """测试服务器健康状态""" + try: + response = requests.get(f"{API_URL}/health") + if response.status_code == 200: + print("✅ 服务器健康检查通过") + return True + else: + print(f"❌ 服务器健康检查失败: {response.status_code}") + return False + except Exception as e: + print(f"❌ 无法连接到服务器: {e}") + return False + +def authenticate(username: str, password: str): + """通过 OAuth 密码流进行身份验证并返回令牌字典""" + url = f"{API_URL}/oauth/token" + data = { + "grant_type": "password", + "username": username, + "password": password, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "scope": "*", + } + + try: + response = requests.post(url, data=data) + if response.status_code == 200: + print("✅ 身份验证成功") + return response.json() + else: + print(f"❌ 身份验证失败: {response.status_code}") + print(f"响应内容: {response.text}") + return None + except Exception as e: + print(f"❌ 身份验证请求失败: {e}") + return None + +def refresh_token(refresh_token: str): + """刷新 OAuth 令牌""" + url = f"{API_URL}/oauth/token" + data = { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "scope": "*", + } + + try: + response = requests.post(url, data=data) + if response.status_code == 200: + print("✅ 令牌刷新成功") + return response.json() + else: + print(f"❌ 令牌刷新失败: {response.status_code}") + print(f"响应内容: {response.text}") + return None + except Exception as e: + print(f"❌ 令牌刷新请求失败: {e}") + return None + +def get_current_user(access_token: str, ruleset: str = "osu"): + """获取认证用户的数据""" + url = f"{API_URL}/api/v2/me/{ruleset}" + headers = {"Authorization": f"Bearer {access_token}"} + + try: + response = requests.get(url, headers=headers) + if response.status_code == 200: + print(f"✅ 成功获取 {ruleset} 模式的用户数据") + return response.json() + else: + print(f"❌ 获取用户数据失败: {response.status_code}") + print(f"响应内容: {response.text}") + return None + except Exception as e: + print(f"❌ 获取用户数据请求失败: {e}") + return None + +def main(): + """主测试函数""" + print("=== osu! API 模拟服务器测试 ===\n") + + # 1. 测试服务器健康状态 + print("1. 检查服务器状态...") + if not test_server_health(): + print("请确保服务器正在运行: uvicorn main:app --reload") + return + + print() + + # 2. 获取用户凭据 + print("2. 用户身份验证...") + username = input("请输入用户名 (默认: Googujiang): ").strip() or "Googujiang" + + import getpass + password = getpass.getpass("请输入密码 (默认: password123): ") or "password123" + + # 3. 身份验证 + print(f"\n3. 正在验证用户 '{username}'...") + token_data = authenticate(username, password) + if not token_data: + print("身份验证失败,请检查用户名和密码") + return + + print(f"访问令牌: {token_data['access_token'][:50]}...") + print(f"刷新令牌: {token_data['refresh_token'][:30]}...") + print(f"令牌有效期: {token_data['expires_in']} 秒") + + # 4. 获取用户数据 + print(f"\n4. 获取用户数据...") + for ruleset in ["osu", "taiko", "fruits", "mania"]: + print(f"\n--- {ruleset.upper()} 模式 ---") + user_data = get_current_user(token_data["access_token"], ruleset) + if user_data: + print(f"用户名: {user_data['username']}") + print(f"国家: {user_data['country']['name']} ({user_data['country_code']})") + print(f"全球排名: {user_data['statistics']['global_rank']}") + print(f"PP: {user_data['statistics']['pp']}") + print(f"游戏次数: {user_data['statistics']['play_count']}") + print(f"命中精度: {user_data['statistics']['hit_accuracy']:.2f}%") + + # 5. 测试令牌刷新 + print(f"\n5. 测试令牌刷新...") + new_token_data = refresh_token(token_data["refresh_token"]) + if new_token_data: + print(f"新访问令牌: {new_token_data['access_token'][:50]}...") + + # 使用新令牌获取用户数据 + print("\n6. 使用新令牌获取用户数据...") + user_data = get_current_user(new_token_data["access_token"]) + if user_data: + print(f"✅ 新令牌有效,用户: {user_data['username']}") + + print("\n=== 测试完成 ===") + +if __name__ == "__main__": + main() diff --git a/test_lazer.py b/test_lazer.py new file mode 100644 index 0000000..8053116 --- /dev/null +++ b/test_lazer.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +""" +Lazer API 系统测试脚本 +验证新的 lazer 表支持是否正常工作 +""" + +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(__file__))) + +from sqlalchemy.orm import Session +from app.dependencies import get_db +from app.database import User, LazerUserProfile, LazerUserStatistics, LazerUserCountry +from app.auth import authenticate_user +from app.utils import convert_db_user_to_api_user + +def test_lazer_tables(): + """测试 lazer 表的基本功能""" + print("测试 Lazer API 表支持...") + + # 获取数据库会话 + db_gen = get_db() + db = next(db_gen) + + try: + # 测试查询用户 + user = db.query(User).first() + if not user: + print("❌ 没有找到用户,请先同步数据") + return False + + print(f"✓ 找到用户: {user.name} (ID: {user.id})") + + # 测试 lazer 资料 + if user.lazer_profile: + print(f"✓ 用户有 lazer 资料: 支持者={user.lazer_profile.is_supporter}") + else: + print("⚠ 用户没有 lazer 资料,将使用默认值") + + # 测试 lazer 统计 + osu_stats = None + for stat in user.lazer_statistics: + if stat.mode == 'osu': + osu_stats = stat + break + + if osu_stats: + print(f"✓ 用户有 osu! 统计: PP={osu_stats.pp}, 游戏次数={osu_stats.play_count}") + else: + print("⚠ 用户没有 osu! 统计,将使用默认值") + + # 测试转换为 API 格式 + api_user = convert_db_user_to_api_user(user, "osu", db) + print(f"✓ 成功转换为 API 用户格式") + print(f" - 用户名: {api_user.username}") + print(f" - 国家: {api_user.country_code}") + print(f" - PP: {api_user.statistics.pp}") + print(f" - 是否支持者: {api_user.is_supporter}") + + return True + + except Exception as e: + print(f"❌ 测试失败: {e}") + import traceback + traceback.print_exc() + return False + finally: + db.close() + +def test_authentication(): + """测试认证功能""" + print("\n测试认证功能...") + + db_gen = get_db() + db = next(db_gen) + + try: + # 尝试认证第一个用户 + user = db.query(User).first() + if not user: + print("❌ 没有用户进行认证测试") + return False + + print(f"✓ 测试用户: {user.name}") + print("⚠ 注意: 实际密码认证需要正确的密码") + + return True + + except Exception as e: + print(f"❌ 认证测试失败: {e}") + return False + finally: + db.close() + +def main(): + """主测试函数""" + print("Lazer API 系统测试") + print("=" * 40) + + # 测试表连接 + success1 = test_lazer_tables() + + # 测试认证 + success2 = test_authentication() + + print("\n" + "=" * 40) + if success1 and success2: + print("🎉 所有测试通过!") + print("\n现在可以:") + print("1. 启动 API 服务器: python main.py") + print("2. 测试 OAuth 认证") + print("3. 调用 /api/v2/me/osu 获取用户信息") + else: + print("❌ 测试失败,请检查:") + print("1. 数据库连接是否正常") + print("2. 是否已运行数据同步脚本") + print("3. lazer 表是否正确创建") + +if __name__ == "__main__": + main() diff --git a/test_password.py b/test_password.py new file mode 100644 index 0000000..20dcb7f --- /dev/null +++ b/test_password.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +""" +测试密码哈希和验证逻辑 +""" + +import hashlib +import bcrypt +from app.auth import get_password_hash, verify_password_legacy, bcrypt_cache + +def test_password_logic(): + """测试密码逻辑""" + print("=== 测试密码哈希和验证逻辑 ===\n") + + # 测试密码 + password = "password123" + print(f"原始密码: {password}") + + # 1. 生成哈希 + print("\n1. 生成密码哈希...") + hashed = get_password_hash(password) + print(f"bcrypt 哈希: {hashed}") + + # 2. 验证密码 + print("\n2. 验证密码...") + is_valid = verify_password_legacy(password, hashed) + print(f"验证结果: {'✅ 成功' if is_valid else '❌ 失败'}") + + # 3. 测试错误密码 + print("\n3. 测试错误密码...") + wrong_password = "wrongpassword" + is_valid_wrong = verify_password_legacy(wrong_password, hashed) + print(f"错误密码验证结果: {'❌ 不应该成功' if is_valid_wrong else '✅ 正确拒绝'}") + + # 4. 测试缓存 + print(f"\n4. 缓存状态:") + print(f"缓存中的条目数: {len(bcrypt_cache)}") + if hashed in bcrypt_cache: + print(f"缓存的 MD5: {bcrypt_cache[hashed]}") + expected_md5 = hashlib.md5(password.encode()).hexdigest().encode() + print(f"期望的 MD5: {expected_md5}") + print(f"缓存匹配: {'✅' if bcrypt_cache[hashed] == expected_md5 else '❌'}") + + # 5. 再次验证(应该使用缓存) + print("\n5. 再次验证(使用缓存)...") + is_valid_cached = verify_password_legacy(password, hashed) + print(f"缓存验证结果: {'✅ 成功' if is_valid_cached else '❌ 失败'}") + + print("\n=== 测试完成 ===") + +if __name__ == "__main__": + test_password_logic()