mirror of
https://github.com/wangdage12/Snap.Server.git
synced 2026-02-18 02:42:12 +08:00
Compare commits
8 Commits
40bd74c101
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d2a620820 | ||
|
|
fb977fdeb5 | ||
|
|
036b64c845 | ||
|
|
01f51d82cd | ||
|
|
c6004bec96 | ||
|
|
cb925f7200 | ||
|
|
6e09869df0 | ||
|
|
6b82806931 |
28
README.md
28
README.md
@@ -3,6 +3,15 @@ Snap.Hutao新后端API
|
|||||||
|
|
||||||
## 部署方法
|
## 部署方法
|
||||||
|
|
||||||
|
> **资源和环境要求**
|
||||||
|
> 服务器硬件:
|
||||||
|
> 最低1核CPU,1GB内存
|
||||||
|
>
|
||||||
|
> 运行环境:
|
||||||
|
> `Windows10`及以上、`Windows Server 2019`及以上、`Linux`
|
||||||
|
> `Python3.12`及以上
|
||||||
|
> `MongoDB`
|
||||||
|
|
||||||
### 在服务器生成RSA密钥
|
### 在服务器生成RSA密钥
|
||||||
|
|
||||||
执行以下代码在根目录生成密钥:
|
执行以下代码在根目录生成密钥:
|
||||||
@@ -111,12 +120,27 @@ pip install -r requirements.txt && python -m gunicorn run:app --bind 0.0.0.0:522
|
|||||||
|
|
||||||
请根据服务器性能调整`--workers`和`--threads`参数。
|
请根据服务器性能调整`--workers`和`--threads`参数。
|
||||||
|
|
||||||
### API文档
|
### API文档和官方开放平台
|
||||||
|
|
||||||
API文档可以在该地址访问:
|
**API文档可以在该地址访问:**
|
||||||
|
|
||||||
https://rdgm3wrj7r.apifox.cn/
|
https://rdgm3wrj7r.apifox.cn/
|
||||||
|
|
||||||
|
> 项目官方API和文件资源服务、仓库镜像为各开源项目免费提供
|
||||||
|
|
||||||
|
本项目官方API地址:https://htserver.wdg.cloudns.ch/api/
|
||||||
|
|
||||||
|
<img width="901" height="522" alt="服务拓补结构" src="https://github.com/user-attachments/assets/9cd2f0d5-372e-46b6-b64b-643df661b445" />
|
||||||
|
|
||||||
|
我们的项目官方服务采用多台服务器,其中日本的主服务器用来提供所有基础服务,甘肃的自建服务器提供加速和负载均衡,以确保服务稳定
|
||||||
|
|
||||||
|
元数据仓库镜像可以随时调用获取元数据仓库API`/git-repository/all`得到
|
||||||
|
由于Git仓库几乎无法被CDN缓存,频繁拉取镜像仓库会对服务器造成压力,请各位在使用量较大的情况下自建仓库镜像
|
||||||
|
|
||||||
|
甘肃服务器只能使用ipv6访问,同时比较不稳定,只能用于加速,不要将它直接作为主要服务使用
|
||||||
|
|
||||||
|
甘肃服务器2为备用服务器,计划在用户量较大时作为API(以负载均衡的方式提供,不在极端情况下不采用)和仓库镜像使用
|
||||||
|
|
||||||
### 注意事项
|
### 注意事项
|
||||||
|
|
||||||
在轻量使用的场景下,可以直接使用MongoDB Atlas的免费套餐,但在高并发场景下,建议使用自建MongoDB服务器以获得更好的性能和稳定性。
|
在轻量使用的场景下,可以直接使用MongoDB Atlas的免费套餐,但在高并发场景下,建议使用自建MongoDB服务器以获得更好的性能和稳定性。
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class ConfigLoader:
|
|||||||
|
|
||||||
return self._config
|
return self._config
|
||||||
|
|
||||||
def get(self, key: str, default=None):
|
def get(self, key: str, default=None) -> Any:
|
||||||
"""获取配置值,支持点号分隔的嵌套键"""
|
"""获取配置值,支持点号分隔的嵌套键"""
|
||||||
config = self.load_config()
|
config = self.load_config()
|
||||||
keys = key.split('.')
|
keys = key.split('.')
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ def init_mongo(uri: str, test_mode=False):
|
|||||||
logger.error(f"MongoDB connection failed: {e}")
|
logger.error(f"MongoDB connection failed: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def generate_code(length=6):
|
def generate_code(length=6) -> str:
|
||||||
"""生成数字验证码"""
|
"""生成数字验证码"""
|
||||||
return ''.join(secrets.choice('0123456789') for _ in range(length))
|
return ''.join(secrets.choice('0123456789') for _ in range(length))
|
||||||
|
|
||||||
def generate_numeric_id(length=8):
|
def generate_numeric_id(length=8) -> str:
|
||||||
"""生成数字ID"""
|
"""生成数字ID"""
|
||||||
return ''.join(secrets.choice(string.digits) for _ in range(length))
|
return ''.join(secrets.choice(string.digits) for _ in range(length))
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ def create_app():
|
|||||||
from routes.gacha_log import gacha_log_bp
|
from routes.gacha_log import gacha_log_bp
|
||||||
from routes.web_api import web_api_bp
|
from routes.web_api import web_api_bp
|
||||||
from routes.misc import misc_bp
|
from routes.misc import misc_bp
|
||||||
|
from routes.download_resource import download_resource_bp
|
||||||
|
|
||||||
app.register_blueprint(announcement_bp, url_prefix="/Announcement")
|
app.register_blueprint(announcement_bp, url_prefix="/Announcement")
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(gacha_log_bp)
|
app.register_blueprint(gacha_log_bp)
|
||||||
app.register_blueprint(web_api_bp)
|
app.register_blueprint(web_api_bp)
|
||||||
app.register_blueprint(misc_bp)
|
app.register_blueprint(misc_bp)
|
||||||
|
app.register_blueprint(download_resource_bp)
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
@app.after_request
|
@app.after_request
|
||||||
|
|||||||
@@ -3,14 +3,42 @@ import datetime
|
|||||||
from flask import current_app
|
from flask import current_app
|
||||||
from app.config_loader import config_loader
|
from app.config_loader import config_loader
|
||||||
|
|
||||||
def create_token(user_id):
|
def create_token(user_id: str) -> str:
|
||||||
|
"""
|
||||||
|
创建JWT访问令牌,有效期由配置文件中的JWT_EXPIRATION_HOURS决定。
|
||||||
|
|
||||||
|
:param user_id: 用户ID
|
||||||
|
:return: JWT 访问令牌
|
||||||
|
"""
|
||||||
payload = {
|
payload = {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"exp": datetime.datetime.utcnow() + datetime.timedelta(hours=config_loader.JWT_EXPIRATION_HOURS)
|
"exp": datetime.datetime.utcnow() + datetime.timedelta(hours=config_loader.JWT_EXPIRATION_HOURS)
|
||||||
}
|
}
|
||||||
return jwt.encode(payload, current_app.config["SECRET_KEY"], algorithm=config_loader.JWT_ALGORITHM)
|
return jwt.encode(payload, current_app.config["SECRET_KEY"], algorithm=config_loader.JWT_ALGORITHM)
|
||||||
|
|
||||||
def verify_token(token):
|
# 创建刷新token,有效期是访问token的两倍
|
||||||
|
def create_refresh_token(user_id: str) -> str:
|
||||||
|
"""
|
||||||
|
创建JWT刷新令牌,有效期为访问令牌的两倍。
|
||||||
|
|
||||||
|
:param user_id: 用户ID
|
||||||
|
:return: JWT 刷新令牌
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"exp": datetime.datetime.utcnow() + datetime.timedelta(hours=config_loader.JWT_EXPIRATION_HOURS * 2)
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, current_app.config["SECRET_KEY"], algorithm=config_loader.JWT_ALGORITHM)
|
||||||
|
|
||||||
|
def verify_token(token: str)-> str | None:
|
||||||
|
"""
|
||||||
|
验证JWT令牌并返回用户ID,如果无效则返回None。
|
||||||
|
|
||||||
|
:param token: JWT令牌字符串
|
||||||
|
:type token: str
|
||||||
|
:return: 用户ID或None
|
||||||
|
:rtype: str | None
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
data = jwt.decode(token, current_app.config["SECRET_KEY"], algorithms=[config_loader.JWT_ALGORITHM])
|
data = jwt.decode(token, current_app.config["SECRET_KEY"], algorithms=[config_loader.JWT_ALGORITHM])
|
||||||
return data["user_id"]
|
return data["user_id"]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
from app.utils.jwt_utils import create_token, verify_token
|
from app.utils.jwt_utils import create_token, verify_token, create_refresh_token
|
||||||
from services.auth_service import (
|
from services.auth_service import (
|
||||||
decrypt_data, send_verification_email, verify_user_credentials,
|
decrypt_data, send_verification_email, verify_user_credentials,
|
||||||
create_user_account, get_user_by_id
|
create_user_account, get_user_by_id
|
||||||
@@ -91,6 +91,8 @@ def passport_register():
|
|||||||
|
|
||||||
# 创建token
|
# 创建token
|
||||||
access_token = create_token(str(new_user['_id']))
|
access_token = create_token(str(new_user['_id']))
|
||||||
|
# 刷新token
|
||||||
|
refresh_token = create_refresh_token(str(new_user['_id']))
|
||||||
logger.info(f"User registered: {decrypted_email}")
|
logger.info(f"User registered: {decrypted_email}")
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -98,7 +100,7 @@ def passport_register():
|
|||||||
"message": "success",
|
"message": "success",
|
||||||
"data": {
|
"data": {
|
||||||
"AccessToken": access_token,
|
"AccessToken": access_token,
|
||||||
"RefreshToken": access_token,
|
"RefreshToken": refresh_token,
|
||||||
"ExpiresIn": config_loader.JWT_EXPIRATION_HOURS * 3600
|
"ExpiresIn": config_loader.JWT_EXPIRATION_HOURS * 3600
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -136,6 +138,7 @@ def passport_login():
|
|||||||
|
|
||||||
# 创建token
|
# 创建token
|
||||||
access_token = create_token(str(user['_id']))
|
access_token = create_token(str(user['_id']))
|
||||||
|
refresh_token = create_refresh_token(str(user['_id']))
|
||||||
logger.info(f"User logged in: {decrypted_email}")
|
logger.info(f"User logged in: {decrypted_email}")
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -144,7 +147,7 @@ def passport_login():
|
|||||||
"l10nKey": "ServerPassportLoginSucceed",
|
"l10nKey": "ServerPassportLoginSucceed",
|
||||||
"data": {
|
"data": {
|
||||||
"AccessToken": access_token,
|
"AccessToken": access_token,
|
||||||
"RefreshToken": access_token,
|
"RefreshToken": refresh_token,
|
||||||
"ExpiresIn": config_loader.JWT_EXPIRATION_HOURS * 3600
|
"ExpiresIn": config_loader.JWT_EXPIRATION_HOURS * 3600
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -214,6 +217,7 @@ def passport_refresh_token():
|
|||||||
})
|
})
|
||||||
|
|
||||||
access_token = create_token(user_id)
|
access_token = create_token(user_id)
|
||||||
|
refresh_token = create_refresh_token(user_id)
|
||||||
logger.info(f"Token refreshed for user_id: {user_id}")
|
logger.info(f"Token refreshed for user_id: {user_id}")
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -221,7 +225,7 @@ def passport_refresh_token():
|
|||||||
"message": "success",
|
"message": "success",
|
||||||
"data": {
|
"data": {
|
||||||
"AccessToken": access_token,
|
"AccessToken": access_token,
|
||||||
"RefreshToken": access_token,
|
"RefreshToken": refresh_token,
|
||||||
"ExpiresIn": config_loader.JWT_EXPIRATION_HOURS * 3600
|
"ExpiresIn": config_loader.JWT_EXPIRATION_HOURS * 3600
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
270
routes/download_resource.py
Normal file
270
routes/download_resource.py
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from app.decorators import require_maintainer_permission
|
||||||
|
from app.extensions import logger
|
||||||
|
from services.download_resource_service import (
|
||||||
|
create_download_resource,
|
||||||
|
get_download_resources,
|
||||||
|
get_download_resource_by_id,
|
||||||
|
update_download_resource,
|
||||||
|
delete_download_resource,
|
||||||
|
get_latest_version
|
||||||
|
)
|
||||||
|
|
||||||
|
download_resource_bp = Blueprint("download_resource", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
# 公开API - 获取下载资源列表
|
||||||
|
|
||||||
|
@download_resource_bp.route('/download-resources', methods=['GET'])
|
||||||
|
def get_public_download_resources():
|
||||||
|
"""
|
||||||
|
获取下载资源列表(公开API)
|
||||||
|
可选查询参数:
|
||||||
|
- package_type: 包类型 (msi/msix),不传则返回所有
|
||||||
|
- is_test: 是否包含测试版本 (true/false),不传则只返回正式版本
|
||||||
|
"""
|
||||||
|
package_type = request.args.get('package_type')
|
||||||
|
is_test_str = request.args.get('is_test')
|
||||||
|
|
||||||
|
# 验证package_type参数
|
||||||
|
if package_type and package_type not in ['msi', 'msix']:
|
||||||
|
return jsonify({
|
||||||
|
"code": 1,
|
||||||
|
"message": "Invalid package_type, must be 'msi' or 'msix'",
|
||||||
|
"data": None
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# 处理is_test参数,默认只返回正式版本
|
||||||
|
is_test = False
|
||||||
|
if is_test_str is not None:
|
||||||
|
is_test = is_test_str.lower() == 'true'
|
||||||
|
|
||||||
|
# 只返回激活的资源
|
||||||
|
resources = get_download_resources(package_type=package_type, is_active=True, is_test=is_test)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": resources
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@download_resource_bp.route('/download-resources/latest', methods=['GET'])
|
||||||
|
def get_latest_download_resource():
|
||||||
|
"""
|
||||||
|
获取最新版本(公开API)
|
||||||
|
可选查询参数:
|
||||||
|
- package_type: 包类型 (msi/msix),不传则返回最新的任意类型
|
||||||
|
- is_test: 是否包含测试版本 (true/false),不传则只返回正式版本
|
||||||
|
"""
|
||||||
|
package_type = request.args.get('package_type')
|
||||||
|
is_test_str = request.args.get('is_test')
|
||||||
|
|
||||||
|
# 验证package_type参数
|
||||||
|
if package_type and package_type not in ['msi', 'msix']:
|
||||||
|
return jsonify({
|
||||||
|
"code": 1,
|
||||||
|
"message": "Invalid package_type, must be 'msi' or 'msix'",
|
||||||
|
"data": None
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# 处理is_test参数
|
||||||
|
is_test = False
|
||||||
|
if is_test_str is not None:
|
||||||
|
is_test = is_test_str.lower() == 'true'
|
||||||
|
|
||||||
|
resource = get_latest_version(package_type=package_type, is_test=is_test)
|
||||||
|
|
||||||
|
if resource:
|
||||||
|
return jsonify({
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": resource
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
"code": 1,
|
||||||
|
"message": "No resource found",
|
||||||
|
"data": None
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
|
||||||
|
# Web管理端API - 增删改查
|
||||||
|
|
||||||
|
@download_resource_bp.route('/web-api/download-resources', methods=['POST'])
|
||||||
|
@require_maintainer_permission
|
||||||
|
def web_api_create_download_resource():
|
||||||
|
"""创建下载资源"""
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
# 验证必需字段
|
||||||
|
required_fields = ['version', 'package_type', 'download_url']
|
||||||
|
if not all(k in data for k in required_fields):
|
||||||
|
return jsonify({
|
||||||
|
"code": 1,
|
||||||
|
"message": f"Missing required fields: {', '.join(required_fields)}",
|
||||||
|
"data": None
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# 验证package_type
|
||||||
|
if data['package_type'] not in ['msi', 'msix']:
|
||||||
|
return jsonify({
|
||||||
|
"code": 1,
|
||||||
|
"message": "Invalid package_type, must be 'msi' or 'msix'",
|
||||||
|
"data": None
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# 添加创建者信息
|
||||||
|
data['created_by'] = str(request.current_user['_id'])
|
||||||
|
|
||||||
|
# 创建资源
|
||||||
|
resource_id = create_download_resource(data)
|
||||||
|
|
||||||
|
if resource_id:
|
||||||
|
logger.info(f"Download resource created with ID: {resource_id} by user: {request.current_user['email']}")
|
||||||
|
return jsonify({
|
||||||
|
"code": 0,
|
||||||
|
"message": "Download resource created successfully",
|
||||||
|
"data": {
|
||||||
|
"id": str(resource_id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
logger.error("Failed to create download resource")
|
||||||
|
return jsonify({
|
||||||
|
"code": 2,
|
||||||
|
"message": "Failed to create download resource",
|
||||||
|
"data": None
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@download_resource_bp.route('/web-api/download-resources', methods=['GET'])
|
||||||
|
@require_maintainer_permission
|
||||||
|
def web_api_get_download_resources():
|
||||||
|
"""获取下载资源列表(管理端,包含所有资源,包括未激活的)"""
|
||||||
|
package_type = request.args.get('package_type')
|
||||||
|
is_active_str = request.args.get('is_active')
|
||||||
|
is_test_str = request.args.get('is_test')
|
||||||
|
|
||||||
|
# 验证package_type参数
|
||||||
|
if package_type and package_type not in ['msi', 'msix']:
|
||||||
|
return jsonify({
|
||||||
|
"code": 1,
|
||||||
|
"message": "Invalid package_type, must be 'msi' or 'msix'",
|
||||||
|
"data": None
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# 处理is_active参数
|
||||||
|
is_active = None
|
||||||
|
if is_active_str is not None:
|
||||||
|
is_active = is_active_str.lower() == 'true'
|
||||||
|
|
||||||
|
# 处理is_test参数
|
||||||
|
is_test = None
|
||||||
|
if is_test_str is not None:
|
||||||
|
is_test = is_test_str.lower() == 'true'
|
||||||
|
|
||||||
|
resources = get_download_resources(package_type=package_type, is_active=is_active, is_test=is_test)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": resources
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@download_resource_bp.route('/web-api/download-resources/<resource_id>', methods=['GET'])
|
||||||
|
@require_maintainer_permission
|
||||||
|
def web_api_get_download_resource(resource_id):
|
||||||
|
"""获取单个下载资源详情"""
|
||||||
|
resource = get_download_resource_by_id(resource_id)
|
||||||
|
|
||||||
|
if resource:
|
||||||
|
return jsonify({
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": resource
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
"code": 1,
|
||||||
|
"message": "Resource not found",
|
||||||
|
"data": None
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@download_resource_bp.route('/web-api/download-resources/<resource_id>', methods=['PUT'])
|
||||||
|
@require_maintainer_permission
|
||||||
|
def web_api_update_download_resource(resource_id):
|
||||||
|
"""更新下载资源"""
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
# 检查资源是否存在
|
||||||
|
existing_resource = get_download_resource_by_id(resource_id)
|
||||||
|
if not existing_resource:
|
||||||
|
return jsonify({
|
||||||
|
"code": 1,
|
||||||
|
"message": "Resource not found",
|
||||||
|
"data": None
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
# 验证package_type(如果提供)
|
||||||
|
if 'package_type' in data and data['package_type'] not in ['msi', 'msix']:
|
||||||
|
return jsonify({
|
||||||
|
"code": 1,
|
||||||
|
"message": "Invalid package_type, must be 'msi' or 'msix'",
|
||||||
|
"data": None
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# 添加更新者信息
|
||||||
|
data['updated_by'] = str(request.current_user['_id'])
|
||||||
|
|
||||||
|
# 更新资源
|
||||||
|
success = update_download_resource(resource_id, data)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"Download resource {resource_id} updated by user: {request.current_user['email']}")
|
||||||
|
return jsonify({
|
||||||
|
"code": 0,
|
||||||
|
"message": "Download resource updated successfully",
|
||||||
|
"data": None
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to update download resource {resource_id}")
|
||||||
|
return jsonify({
|
||||||
|
"code": 2,
|
||||||
|
"message": "Failed to update download resource",
|
||||||
|
"data": None
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@download_resource_bp.route('/web-api/download-resources/<resource_id>', methods=['DELETE'])
|
||||||
|
@require_maintainer_permission
|
||||||
|
def web_api_delete_download_resource(resource_id):
|
||||||
|
"""删除下载资源"""
|
||||||
|
# 检查资源是否存在
|
||||||
|
existing_resource = get_download_resource_by_id(resource_id)
|
||||||
|
if not existing_resource:
|
||||||
|
return jsonify({
|
||||||
|
"code": 1,
|
||||||
|
"message": "Resource not found",
|
||||||
|
"data": None
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
# 删除资源
|
||||||
|
success = delete_download_resource(resource_id)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"Download resource {resource_id} deleted by user: {request.current_user['email']}")
|
||||||
|
return jsonify({
|
||||||
|
"code": 0,
|
||||||
|
"message": "Download resource deleted successfully",
|
||||||
|
"data": None
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to delete download resource {resource_id}")
|
||||||
|
return jsonify({
|
||||||
|
"code": 2,
|
||||||
|
"message": "Failed to delete download resource",
|
||||||
|
"data": None
|
||||||
|
}), 500
|
||||||
@@ -119,6 +119,7 @@ def gacha_log_retrieve():
|
|||||||
|
|
||||||
filtered_items = retrieve_gacha_log(user_id, uid, end_ids)
|
filtered_items = retrieve_gacha_log(user_id, uid, end_ids)
|
||||||
logger.info(f"Gacha log retrieved for user_id: {user_id}, uid: {uid}, items count: {len(filtered_items)}")
|
logger.info(f"Gacha log retrieved for user_id: {user_id}, uid: {uid}, items count: {len(filtered_items)}")
|
||||||
|
logger.debug(f"end_ids: {end_ids}")
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"retcode": 0,
|
"retcode": 0,
|
||||||
|
|||||||
@@ -239,9 +239,15 @@ def web_api_get_users():
|
|||||||
"data": None
|
"data": None
|
||||||
}), 403
|
}), 403
|
||||||
|
|
||||||
# 获取搜索参数
|
# 获取查询参数
|
||||||
q = request.args.get("q", "").strip()
|
q = request.args.get("q", "").strip()
|
||||||
users = get_users_with_search(q)
|
role = request.args.get("role", "").strip() if request.args.get("role") else None
|
||||||
|
email = request.args.get("email", "").strip() if request.args.get("email") else None
|
||||||
|
username = request.args.get("username", "").strip() if request.args.get("username") else None
|
||||||
|
id_param = request.args.get("id", "").strip() if request.args.get("id") else None
|
||||||
|
is_licensed = request.args.get("is", "").strip() if request.args.get("is") else None
|
||||||
|
|
||||||
|
users = get_users_with_search(q, role, email, username, id_param, is_licensed)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"code": 0,
|
"code": 0,
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ from app.extensions import client, logger
|
|||||||
from app.config import Config
|
from app.config import Config
|
||||||
|
|
||||||
def get_announcements(request_data: list):
|
def get_announcements(request_data: list):
|
||||||
|
"""
|
||||||
|
获取公告列表,过滤掉用户已关闭的公告
|
||||||
|
|
||||||
|
:param request_data: 用户已关闭的公告ID列表
|
||||||
|
:type request_data: list
|
||||||
|
"""
|
||||||
if Config.ISTEST_MODE:
|
if Config.ISTEST_MODE:
|
||||||
return []
|
return []
|
||||||
# 记录请求体到日志,请求体中是用户已关闭的公告ID列表
|
# 记录请求体到日志,请求体中是用户已关闭的公告ID列表
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import SendEmailTool
|
|||||||
import re
|
import re
|
||||||
import base64
|
import base64
|
||||||
|
|
||||||
def decrypt_data(encrypted_data):
|
def decrypt_data(encrypted_data: str) -> str:
|
||||||
"""使用RSA私钥解密数据"""
|
"""使用RSA私钥解密数据"""
|
||||||
try:
|
try:
|
||||||
private_key_file = config_loader.RSA_PRIVATE_KEY_FILE
|
private_key_file = config_loader.RSA_PRIVATE_KEY_FILE
|
||||||
@@ -26,7 +26,7 @@ def decrypt_data(encrypted_data):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def send_verification_email(email, code, ACTION_NAME="注册", EXPIRE_MINUTES=None):
|
def send_verification_email(email: str, code: str, ACTION_NAME="注册", EXPIRE_MINUTES=None) -> bool:
|
||||||
"""发送验证码邮件,目前只有注册场景,后续再扩展其他场景"""
|
"""发送验证码邮件,目前只有注册场景,后续再扩展其他场景"""
|
||||||
try:
|
try:
|
||||||
subject = Config.EMAIL_SUBJECT
|
subject = Config.EMAIL_SUBJECT
|
||||||
@@ -151,7 +151,7 @@ def send_verification_email(email, code, ACTION_NAME="注册", EXPIRE_MINUTES=No
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def verify_user_credentials(email, password):
|
def verify_user_credentials(email: str, password: str) -> dict | None:
|
||||||
"""验证用户凭据"""
|
"""验证用户凭据"""
|
||||||
user = client.ht_server.users.find_one({"email": email})
|
user = client.ht_server.users.find_one({"email": email})
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ def verify_user_credentials(email, password):
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def create_user_account(email, password):
|
def create_user_account(email: str, password: str) -> dict | None:
|
||||||
"""创建新用户账户"""
|
"""创建新用户账户"""
|
||||||
# 检查用户是否已存在
|
# 检查用户是否已存在
|
||||||
existing_user = client.ht_server.users.find_one({"email": email})
|
existing_user = client.ht_server.users.find_one({"email": email})
|
||||||
@@ -191,7 +191,7 @@ def create_user_account(email, password):
|
|||||||
return new_user
|
return new_user
|
||||||
|
|
||||||
|
|
||||||
def get_user_by_id(user_id):
|
def get_user_by_id(user_id: str) -> dict | None:
|
||||||
"""根据ID获取用户信息"""
|
"""根据ID获取用户信息"""
|
||||||
try:
|
try:
|
||||||
user = client.ht_server.users.find_one({"_id": ObjectId(user_id)})
|
user = client.ht_server.users.find_one({"_id": ObjectId(user_id)})
|
||||||
@@ -203,32 +203,82 @@ def get_user_by_id(user_id):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_users_with_search(query_text=""):
|
def get_users_with_search(query_text="", role=None, email=None, username=None, id=None, is_licensed=None) -> list:
|
||||||
"""获取用户列表,支持搜索"""
|
"""获取用户列表,支持多种筛选条件"""
|
||||||
import re
|
import re
|
||||||
|
|
||||||
# 构建查询条件
|
# 构建查询条件
|
||||||
query = {}
|
query = {}
|
||||||
or_conditions = []
|
and_conditions = []
|
||||||
|
|
||||||
|
# 通用搜索(q 参数)- 匹配用户名、邮箱、ID
|
||||||
if query_text:
|
if query_text:
|
||||||
|
or_conditions = []
|
||||||
# 用户名模糊搜索
|
# 用户名模糊搜索
|
||||||
or_conditions.append({
|
or_conditions.append({
|
||||||
"UserName": {"$regex": re.escape(query_text), "$options": "i"}
|
"UserName": {"$regex": re.escape(query_text), "$options": "i"}
|
||||||
})
|
})
|
||||||
|
|
||||||
# 邮箱模糊搜索
|
# 邮箱模糊搜索
|
||||||
or_conditions.append({
|
or_conditions.append({
|
||||||
"email": {"$regex": re.escape(query_text), "$options": "i"}
|
"email": {"$regex": re.escape(query_text), "$options": "i"}
|
||||||
})
|
})
|
||||||
|
|
||||||
# _id 搜索(支持完整或前缀)
|
# _id 搜索(支持完整或前缀)
|
||||||
if ObjectId.is_valid(query_text):
|
if ObjectId.is_valid(query_text):
|
||||||
or_conditions.append({
|
or_conditions.append({
|
||||||
"_id": ObjectId(query_text)
|
"_id": ObjectId(query_text)
|
||||||
})
|
})
|
||||||
|
and_conditions.append({"$or": or_conditions})
|
||||||
|
|
||||||
query = {"$or": or_conditions}
|
# 按角色筛选
|
||||||
|
if role:
|
||||||
|
if role == "maintainer":
|
||||||
|
and_conditions.append({"IsMaintainer": True})
|
||||||
|
elif role == "developer":
|
||||||
|
and_conditions.append({"IsLicensedDeveloper": True})
|
||||||
|
elif role == "user":
|
||||||
|
# user 表示既不是 maintainer 也不是 developer
|
||||||
|
and_conditions.append({
|
||||||
|
"$and": [
|
||||||
|
{"IsMaintainer": {"$ne": True}},
|
||||||
|
{"IsLicensedDeveloper": {"$ne": True}}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
# 按邮箱筛选(支持模糊匹配)
|
||||||
|
if email:
|
||||||
|
and_conditions.append({
|
||||||
|
"email": {"$regex": re.escape(email), "$options": "i"}
|
||||||
|
})
|
||||||
|
|
||||||
|
# 按用户名筛选(支持模糊匹配)
|
||||||
|
if username:
|
||||||
|
and_conditions.append({
|
||||||
|
"UserName": {"$regex": re.escape(username), "$options": "i"}
|
||||||
|
})
|
||||||
|
|
||||||
|
# 按用户ID筛选(支持模糊匹配)
|
||||||
|
if id:
|
||||||
|
if ObjectId.is_valid(id):
|
||||||
|
and_conditions.append({"_id": ObjectId(id)})
|
||||||
|
else:
|
||||||
|
# 如果不是有效的 ObjectId,尝试匹配字符串形式的 _id
|
||||||
|
and_conditions.append({
|
||||||
|
"_id": {"$regex": re.escape(id), "$options": "i"}
|
||||||
|
})
|
||||||
|
|
||||||
|
# 按状态筛选
|
||||||
|
if is_licensed:
|
||||||
|
if is_licensed == "licensed":
|
||||||
|
and_conditions.append({"IsLicensedDeveloper": True})
|
||||||
|
elif is_licensed == "not-licensed":
|
||||||
|
and_conditions.append({"IsLicensedDeveloper": False})
|
||||||
|
|
||||||
|
# 构建 AND 查询
|
||||||
|
if and_conditions:
|
||||||
|
if len(and_conditions) == 1:
|
||||||
|
query = and_conditions[0]
|
||||||
|
else:
|
||||||
|
query = {"$and": and_conditions}
|
||||||
|
|
||||||
# 查询数据库(排除密码)
|
# 查询数据库(排除密码)
|
||||||
cursor = client.ht_server.users.find(query, {"password": 0})
|
cursor = client.ht_server.users.find(query, {"password": 0})
|
||||||
|
|||||||
241
services/download_resource_service.py
Normal file
241
services/download_resource_service.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import datetime
|
||||||
|
from app.extensions import client, logger
|
||||||
|
from app.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
def create_download_resource(data):
|
||||||
|
"""
|
||||||
|
创建下载资源
|
||||||
|
|
||||||
|
:param data: 包含以下字段的数据
|
||||||
|
- version: 版本号
|
||||||
|
- package_type: 包类型 (msi/msix)
|
||||||
|
- download_url: 下载链接
|
||||||
|
- features: 新功能描述
|
||||||
|
- file_size: 文件大小 (可选)
|
||||||
|
- file_hash: 文件哈希 (可选)
|
||||||
|
- is_active: 是否激活 (可选,默认为True)
|
||||||
|
- is_test: 是否为测试版本 (可选,默认为False)
|
||||||
|
:return: 创建的资源ID或None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
resource_doc = {
|
||||||
|
"version": data['version'],
|
||||||
|
"package_type": data['package_type'],
|
||||||
|
"download_url": data['download_url'],
|
||||||
|
"features": data.get('features', ''),
|
||||||
|
"file_size": data.get('file_size'),
|
||||||
|
"file_hash": data.get('file_hash'),
|
||||||
|
"is_active": data.get('is_active', True),
|
||||||
|
"is_test": data.get('is_test', False),
|
||||||
|
"created_at": datetime.datetime.utcnow(),
|
||||||
|
"created_by": data.get('created_by')
|
||||||
|
}
|
||||||
|
|
||||||
|
result = client.ht_server.download_resources.insert_one(resource_doc)
|
||||||
|
logger.info(f"Download resource created with ID: {result.inserted_id}")
|
||||||
|
return result.inserted_id
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create download resource: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_download_resources(package_type=None, is_active=None, is_test=None):
|
||||||
|
"""
|
||||||
|
获取下载资源列表
|
||||||
|
|
||||||
|
:param package_type: 包类型过滤 (msi/msix),None表示获取所有
|
||||||
|
:param is_active: 是否激活过滤,None表示获取所有
|
||||||
|
:param is_test: 是否为测试版本过滤,None表示获取所有
|
||||||
|
:return: 资源列表
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
query = {}
|
||||||
|
if package_type:
|
||||||
|
query['package_type'] = package_type
|
||||||
|
if is_active is not None:
|
||||||
|
query['is_active'] = is_active
|
||||||
|
if is_test is not None:
|
||||||
|
# 如果查询非测试版本,需要包含 is_test=false 或 is_test 字段不存在的记录
|
||||||
|
if is_test:
|
||||||
|
query['is_test'] = True
|
||||||
|
else:
|
||||||
|
query['$or'] = [
|
||||||
|
{'is_test': False},
|
||||||
|
{'is_test': {'$exists': False}}
|
||||||
|
]
|
||||||
|
|
||||||
|
resources = list(client.ht_server.download_resources.find(query, sort=[("created_at", -1)]))
|
||||||
|
|
||||||
|
# 移除 _id 字段并转换日期
|
||||||
|
result = []
|
||||||
|
for r in resources:
|
||||||
|
r = dict(r)
|
||||||
|
# 转换_id为字符串并存为id字段
|
||||||
|
r['id'] = str(r.pop('_id'))
|
||||||
|
# 如果 is_test 字段不存在,默认设置为 False
|
||||||
|
if 'is_test' not in r:
|
||||||
|
r['is_test'] = False
|
||||||
|
# 转换datetime为配置时区的ISO格式字符串
|
||||||
|
if 'created_at' in r and isinstance(r['created_at'], datetime.datetime):
|
||||||
|
dt = r['created_at'].replace(tzinfo=datetime.timezone.utc)
|
||||||
|
dt = dt.astimezone(Config.TIMEZONE)
|
||||||
|
r['created_at'] = dt.isoformat()
|
||||||
|
if 'updated_at' in r and isinstance(r['updated_at'], datetime.datetime):
|
||||||
|
dt = r['updated_at'].replace(tzinfo=datetime.timezone.utc)
|
||||||
|
dt = dt.astimezone(Config.TIMEZONE)
|
||||||
|
r['updated_at'] = dt.isoformat()
|
||||||
|
result.append(r)
|
||||||
|
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get download resources: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def get_download_resource_by_id(resource_id):
|
||||||
|
"""
|
||||||
|
根据ID获取下载资源
|
||||||
|
|
||||||
|
:param resource_id: 资源ID
|
||||||
|
:return: 资源对象或None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from bson import ObjectId
|
||||||
|
resource = client.ht_server.download_resources.find_one({"_id": ObjectId(resource_id)})
|
||||||
|
|
||||||
|
if resource:
|
||||||
|
resource = dict(resource)
|
||||||
|
resource.pop('_id', None)
|
||||||
|
# 如果 is_test 字段不存在,默认设置为 False
|
||||||
|
if 'is_test' not in resource:
|
||||||
|
resource['is_test'] = False
|
||||||
|
# 转换datetime为配置时区的ISO格式字符串
|
||||||
|
if 'created_at' in resource and isinstance(resource['created_at'], datetime.datetime):
|
||||||
|
dt = resource['created_at'].replace(tzinfo=datetime.timezone.utc)
|
||||||
|
dt = dt.astimezone(Config.TIMEZONE)
|
||||||
|
resource['created_at'] = dt.isoformat()
|
||||||
|
if 'updated_at' in resource and isinstance(resource['updated_at'], datetime.datetime):
|
||||||
|
dt = resource['updated_at'].replace(tzinfo=datetime.timezone.utc)
|
||||||
|
dt = dt.astimezone(Config.TIMEZONE)
|
||||||
|
resource['updated_at'] = dt.isoformat()
|
||||||
|
return resource
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get download resource by ID: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def update_download_resource(resource_id, data):
|
||||||
|
"""
|
||||||
|
更新下载资源
|
||||||
|
|
||||||
|
:param resource_id: 资源ID
|
||||||
|
:param data: 要更新的字段
|
||||||
|
:return: 是否成功
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from bson import ObjectId
|
||||||
|
|
||||||
|
# 构建更新数据
|
||||||
|
update_data = {"updated_at": datetime.datetime.utcnow()}
|
||||||
|
|
||||||
|
if 'version' in data:
|
||||||
|
update_data['version'] = data['version']
|
||||||
|
if 'package_type' in data:
|
||||||
|
update_data['package_type'] = data['package_type']
|
||||||
|
if 'download_url' in data:
|
||||||
|
update_data['download_url'] = data['download_url']
|
||||||
|
if 'features' in data:
|
||||||
|
update_data['features'] = data['features']
|
||||||
|
if 'file_size' in data:
|
||||||
|
update_data['file_size'] = data['file_size']
|
||||||
|
if 'file_hash' in data:
|
||||||
|
update_data['file_hash'] = data['file_hash']
|
||||||
|
if 'is_active' in data:
|
||||||
|
update_data['is_active'] = data['is_active']
|
||||||
|
if 'is_test' in data:
|
||||||
|
update_data['is_test'] = data['is_test']
|
||||||
|
if 'updated_by' in data:
|
||||||
|
update_data['updated_by'] = data['updated_by']
|
||||||
|
|
||||||
|
result = client.ht_server.download_resources.update_one(
|
||||||
|
{"_id": ObjectId(resource_id)},
|
||||||
|
{"$set": update_data}
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.modified_count > 0:
|
||||||
|
logger.info(f"Download resource {resource_id} updated successfully")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update download resource: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def delete_download_resource(resource_id):
|
||||||
|
"""
|
||||||
|
删除下载资源
|
||||||
|
|
||||||
|
:param resource_id: 资源ID
|
||||||
|
:return: 是否成功
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from bson import ObjectId
|
||||||
|
result = client.ht_server.download_resources.delete_one({"_id": ObjectId(resource_id)})
|
||||||
|
|
||||||
|
if result.deleted_count > 0:
|
||||||
|
logger.info(f"Download resource {resource_id} deleted successfully")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete download resource: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest_version(package_type=None, is_test=False):
|
||||||
|
"""
|
||||||
|
获取最新版本
|
||||||
|
|
||||||
|
:param package_type: 包类型 (msi/msix),None表示获取所有类型的最新版本
|
||||||
|
:param is_test: 是否包含测试版本,默认为False(只返回正式版本)
|
||||||
|
:return: 资源对象或None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
query = {"is_active": True}
|
||||||
|
if not is_test:
|
||||||
|
# 如果查询非测试版本,需要包含 is_test=false 或 is_test 字段不存在的记录
|
||||||
|
query['$or'] = [
|
||||||
|
{'is_test': False},
|
||||||
|
{'is_test': {'$exists': False}}
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
query['is_test'] = True
|
||||||
|
if package_type:
|
||||||
|
query['package_type'] = package_type
|
||||||
|
|
||||||
|
resource = client.ht_server.download_resources.find_one(
|
||||||
|
query,
|
||||||
|
sort=[("created_at", -1)]
|
||||||
|
)
|
||||||
|
|
||||||
|
if resource:
|
||||||
|
resource = dict(resource)
|
||||||
|
resource.pop('_id', None)
|
||||||
|
# 如果 is_test 字段不存在,默认设置为 False
|
||||||
|
if 'is_test' not in resource:
|
||||||
|
resource['is_test'] = False
|
||||||
|
# 转换datetime为配置时区的ISO格式字符串
|
||||||
|
if 'created_at' in resource and isinstance(resource['created_at'], datetime.datetime):
|
||||||
|
dt = resource['created_at'].replace(tzinfo=datetime.timezone.utc)
|
||||||
|
dt = dt.astimezone(Config.TIMEZONE)
|
||||||
|
resource['created_at'] = dt.isoformat()
|
||||||
|
if 'updated_at' in resource and isinstance(resource['updated_at'], datetime.datetime):
|
||||||
|
dt = resource['updated_at'].replace(tzinfo=datetime.timezone.utc)
|
||||||
|
dt = dt.astimezone(Config.TIMEZONE)
|
||||||
|
resource['updated_at'] = dt.isoformat()
|
||||||
|
return resource
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get latest version: {e}")
|
||||||
|
return None
|
||||||
@@ -1,5 +1,17 @@
|
|||||||
from app.extensions import client, logger
|
from app.extensions import client, logger
|
||||||
|
|
||||||
|
"""
|
||||||
|
注意!记录中有两种类型,GachaType和QueryType(uigf_gacha_type),GachaType多了一个400类型,其实就是QueryType的301类型,客户端传的end_ids是按QueryType来的,如果按照GachaType来筛选会多出400类型的记录
|
||||||
|
映射关系
|
||||||
|
|
||||||
|
| `uigf_gacha_type` | `gacha_type` |
|
||||||
|
|-------------------|----------------|
|
||||||
|
| `100` | `100` |
|
||||||
|
| `200` | `200` |
|
||||||
|
| `301` | `301` or `400` |
|
||||||
|
| `302` | `302` |
|
||||||
|
| `500` | `500` |
|
||||||
|
"""
|
||||||
|
|
||||||
def get_gacha_log_entries(user_id):
|
def get_gacha_log_entries(user_id):
|
||||||
"""获取用户的祈愿记录条目列表"""
|
"""获取用户的祈愿记录条目列表"""
|
||||||
@@ -41,6 +53,9 @@ def get_gacha_log_end_ids(user_id, uid):
|
|||||||
if gacha_type in end_ids:
|
if gacha_type in end_ids:
|
||||||
end_ids[gacha_type] = max(end_ids[gacha_type], item_id)
|
end_ids[gacha_type] = max(end_ids[gacha_type], item_id)
|
||||||
|
|
||||||
|
# 400类型对应301类型
|
||||||
|
end_ids["400"] = end_ids["301"]
|
||||||
|
|
||||||
return end_ids
|
return end_ids
|
||||||
|
|
||||||
|
|
||||||
@@ -82,6 +97,11 @@ def retrieve_gacha_log(user_id, uid, end_ids):
|
|||||||
|
|
||||||
# 筛选出比end_ids更旧的记录
|
# 筛选出比end_ids更旧的记录
|
||||||
filtered_items = []
|
filtered_items = []
|
||||||
|
|
||||||
|
# 需要将end_ids的key从QueryType转换为GachaType,给400赋值为301的值即可
|
||||||
|
if "301" in end_ids:
|
||||||
|
end_ids["400"] = end_ids["301"]
|
||||||
|
|
||||||
for item in gacha_log['data']:
|
for item in gacha_log['data']:
|
||||||
gacha_type = str(item.get('GachaType', ''))
|
gacha_type = str(item.get('GachaType', ''))
|
||||||
item_id = item.get('Id', 0)
|
item_id = item.get('Id', 0)
|
||||||
|
|||||||
Reference in New Issue
Block a user