上传源码
This commit is contained in:
4
.env.client
Normal file
4
.env.client
Normal file
@@ -0,0 +1,4 @@
|
||||
# osu! API 客户端配置
|
||||
OSU_CLIENT_ID=5
|
||||
OSU_CLIENT_SECRET=FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk
|
||||
OSU_API_URL=http://localhost:8000
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -205,3 +205,4 @@ cython_debug/
|
||||
marimo/_static/
|
||||
marimo/_lsp/
|
||||
__marimo__/
|
||||
bancho.py-master/*
|
||||
140
DATA_SYNC_GUIDE.md
Normal file
140
DATA_SYNC_GUIDE.md
Normal file
@@ -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. 验证原始表数据完整性
|
||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@@ -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"]
|
||||
214
README.md
Normal file
214
README.md
Normal file
@@ -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 <repository-url>
|
||||
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!
|
||||
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# 初始化文件
|
||||
159
app/auth.py
Normal file
159
app/auth.py
Normal file
@@ -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()
|
||||
25
app/config.py
Normal file
25
app/config.py
Normal file
@@ -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()
|
||||
403
app/database copy.py
Normal file
403
app/database copy.py
Normal file
@@ -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")
|
||||
426
app/database.py
Normal file
426
app/database.py
Normal file
@@ -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")
|
||||
29
app/dependencies.py
Normal file
29
app/dependencies.py
Normal file
@@ -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
|
||||
226
app/models.py
Normal file
226
app/models.py
Normal file
@@ -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"
|
||||
447
app/utils.py
Normal file
447
app/utils.py
Normal file
@@ -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()
|
||||
292
create_sample_data.py
Normal file
292
create_sample_data.py
Normal file
@@ -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了。")
|
||||
50
docker-compose.yml
Normal file
50
docker-compose.yml
Normal file
@@ -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:
|
||||
279
main.py
Normal file
279
main.py
Normal file
@@ -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
|
||||
)
|
||||
16
migrations/add_lazer_rank_fields.sql
Normal file
16
migrations/add_lazer_rank_fields.sql
Normal file
@@ -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());
|
||||
421
migrations/add_missing_fields.sql
Normal file
421
migrations/add_missing_fields.sql
Normal file
@@ -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='功能扩展占位表';
|
||||
486
migrations/base.sql
Normal file
486
migrations/base.sql
Normal file
@@ -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');
|
||||
209
migrations/custom_beatmaps.sql
Normal file
209
migrations/custom_beatmaps.sql
Normal file
@@ -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 ;
|
||||
477
migrations/migrations.sql
Normal file
477
migrations/migrations.sql
Normal file
@@ -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);
|
||||
337
migrations/sync_legacy_data.sql
Normal file
337
migrations/sync_legacy_data.sql
Normal file
@@ -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;
|
||||
59
osu_api_example.py
Normal file
59
osu_api_example.py
Normal file
@@ -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)
|
||||
126
quick_sync.py
Normal file
126
quick_sync.py
Normal file
@@ -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()
|
||||
13
requirements.txt
Normal file
13
requirements.txt
Normal file
@@ -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
|
||||
233
sync_data.py
Normal file
233
sync_data.py
Normal file
@@ -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()
|
||||
157
test_api.py
Normal file
157
test_api.py
Normal file
@@ -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()
|
||||
120
test_lazer.py
Normal file
120
test_lazer.py
Normal file
@@ -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()
|
||||
51
test_password.py
Normal file
51
test_password.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user