diff --git a/.github/scripts/generate_config_doc.py b/.github/scripts/generate_config_doc.py new file mode 100644 index 0000000..1e20f72 --- /dev/null +++ b/.github/scripts/generate_config_doc.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import datetime +from enum import Enum +import importlib.util +import json +from pathlib import Path +import sys +from types import NoneType, UnionType +from typing import Any, Union, get_origin + +from pydantic import AliasChoices, BaseModel, HttpUrl +from pydantic_settings import BaseSettings + +file_path = Path("./app/config.py").resolve() + +spec = importlib.util.spec_from_file_location("config", str(file_path)) +module = importlib.util.module_from_spec(spec) # pyright: ignore[reportArgumentType] +sys.modules["my_module"] = module +spec.loader.exec_module(module) # pyright: ignore[reportOptionalMemberAccess] + +model: type[BaseSettings] = module.Settings + +commit = sys.argv[1] if len(sys.argv) > 1 else "unknown" + +doc = [] +uncategorized = [] + + +def new_paragraph(name: str, has_sub_paragraph: bool) -> None: + doc.append("") + doc.append(f"## {name}") + if desc := model.model_config["json_schema_extra"]["paragraphs_desc"].get(name): # type: ignore + doc.append(desc) + if not has_sub_paragraph: + doc.append("| 变量名 | 描述 | 类型 | 默认值 |") + doc.append("|------|------|--------|------|") + + +def new_sub_paragraph(name: str) -> None: + doc.append("") + doc.append(f"### {name}") + doc.append("| 变量名 | 描述 | 类型 | 默认值 |") + doc.append("|------|------|--------|------|") + + +def serialize_default(value: Any) -> str: + if isinstance(value, Enum): + return value.value + if isinstance(value, str): + return value or '""' + try: + if isinstance(value, BaseModel): + return value.model_dump_json() + return json.dumps(value, ensure_ascii=False) + except Exception: + return str(value) + + +BASE_TYPE_MAPPING = { + str: "string", + int: "integer", + float: "float", + bool: "boolean", + list: "array", + dict: "object", + NoneType: "null", + HttpUrl: "string (url)", +} + + +def mapping_type(typ: type) -> str: + base_type = BASE_TYPE_MAPPING.get(typ) + if base_type: + return base_type + if (origin := get_origin(typ)) is Union or origin is UnionType: + args = list(typ.__args__) + if len(args) == 1: + return mapping_type(args[0]) + return " / ".join(mapping_type(a) for a in args) + elif get_origin(typ) is list: + args = typ.__args__ + if len(args) == 1: + return f"array[{mapping_type(args[0])}]" + return "array" + if issubclass(typ, Enum): + return f"enum({', '.join([e.value for e in typ])})" + elif issubclass(typ, BaseSettings): + return typ.__name__ + return "unknown" + + +last_paragraph = "" +last_sub_paragraph = "" +for name, field in model.model_fields.items(): + if len(field.metadata) == 0: + uncategorized.append((name, field)) + continue + sub_paragraph = "" + paragraph = field.metadata[0] + if len(field.metadata) > 1 and isinstance(field.metadata[1], str): + sub_paragraph = field.metadata[1] + if paragraph != last_paragraph: + last_paragraph = paragraph + new_paragraph(paragraph, has_sub_paragraph=bool(sub_paragraph)) + if sub_paragraph and sub_paragraph != last_sub_paragraph: + last_sub_paragraph = sub_paragraph + new_sub_paragraph(sub_paragraph) + + alias = field.alias or name + aliases = [] + other_aliases = field.validation_alias + if isinstance(other_aliases, str): + aliases.append(other_aliases) + elif isinstance(other_aliases, AliasChoices): + for a in other_aliases.convert_to_aliases(): + aliases.extend(a) + + ins_doc = f"({', '.join([a.upper() for a in aliases])}) " if aliases else "" + doc.append( + f"| {alias.upper()} {ins_doc}| {field.description or ''} " + f"| {mapping_type(field.annotation)} | `{serialize_default(field.default)}` |" # pyright: ignore[reportArgumentType] + ) + +doc.extend( + [ + module.SPECTATOR_DOC, + "", + f"> 上次生成:{datetime.datetime.now(datetime.UTC).strftime('%Y-%m-%d %H:%M:%S %Z')}", + f"于提交 {f'[`{commit}`](https://github.com/GooGuTeam/g0v0-server/commit/{commit})' if commit != 'unknown' else 'unknown'}", # noqa: E501 + "", + "> **注意: 在生产环境中,请务必更改默认的密钥和密码!**", + ] +) +print("\n".join(doc)) diff --git a/.github/workflows/generate-configuration-doc.yml b/.github/workflows/generate-configuration-doc.yml new file mode 100644 index 0000000..d3f4420 --- /dev/null +++ b/.github/workflows/generate-configuration-doc.yml @@ -0,0 +1,56 @@ +name: Generate configuration Docs to Wiki + +on: + push: + branches: + - main + paths: + - "app/config.py" + - ".github/scripts/generate_config_doc.py" + workflow_dispatch: + +permissions: + contents: write + +jobs: + generate-wiki: + runs-on: ubuntu-latest + + steps: + - name: Checkout main repository + uses: actions/checkout@v5 + with: + path: project + + - name: Checkout repo + uses: actions/checkout@v5 + with: + repository: ${{ github.repository }}.wiki + token: ${{ secrets.GITHUB_TOKEN }} + path: wiki + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: 3.12 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pydantic pydantic-settings + + - name: Generate Markdown + run: | + cd project + python ./.github/scripts/generate_config_doc.py ${{ github.sha }} > ../wiki/Configuration.md + + - name: Commit and push to Wiki + run: | + cd wiki + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add . + git commit -m "Update configuration docs from Actions [skip ci]" || echo "No changes" + git push origin main + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/app/config.py b/app/config.py index 51114b5..0146121 100644 --- a/app/config.py +++ b/app/config.py @@ -1,11 +1,9 @@ -from __future__ import annotations - +# ruff: noqa: I002 from enum import Enum from typing import Annotated, Any from pydantic import ( AliasChoices, - BeforeValidator, Field, HttpUrl, ValidationInfo, @@ -14,23 +12,6 @@ from pydantic import ( from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict -def _parse_list(v): - if v is None or v == "" or str(v).strip() in ("[]", "{}"): - return [] - if isinstance(v, list): - return v - s = str(v).strip() - try: - import json - - parsed = json.loads(s) - if isinstance(parsed, list): - return parsed - except Exception: - pass - return [x.strip() for x in s.split(",") if x.strip()] - - class AWSS3StorageSettings(BaseSettings): s3_access_key_id: str s3_secret_access_key: str @@ -57,43 +38,214 @@ class StorageServiceType(str, Enum): AWS_S3 = "s3" +SPECTATOR_DOC = """ +## 旁观服务器设置 +| 变量名 | 描述 | 类型 | 默认值 | +|--------|------|--------|--------| +| `SAVE_REPLAYS` | 是否保存回放,设置为 `1` 为启用 | boolean | `0` | +| `REDIS_HOST` | Redis 服务器地址 | string | `localhost` | +| `SHARED_INTEROP_DOMAIN` | API 服务器(即本服务)地址 | string (url) | `http://localhost:8000` | +| `SERVER_PORT` | 旁观服务器端口 | integer | `8006` | +| `SP_SENTRY_DSN` | 旁观服务器的 Sentry DSN | string | `null` | +""" + + class Settings(BaseSettings): - model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="allow") + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="allow", + json_schema_extra={ + "paragraphs_desc": { + "Fetcher 设置": "Fetcher 用于从 osu! 官方 API 获取数据,使用 osu! 官方 API 的 OAuth 2.0 认证", + "监控设置": ( + "配置应用的监控选项,如 Sentry 和 New Relic。\n\n" + "将 newrelic.ini 配置文件放入项目根目录即可自动启用 New Relic 监控。" + "如果配置文件不存在或 newrelic 包未安装,将跳过 New Relic 初始化。" + ), + "存储服务设置": """用于存储回放文件、头像等静态资源。 + +### 本地存储 (推荐用于开发环境) + +本地存储将文件保存在服务器的本地文件系统中,适合开发和小规模部署。 + +```bash +STORAGE_SERVICE="local" +STORAGE_SETTINGS='{"local_storage_path": "./storage"}' +``` + +### Cloudflare R2 存储 (推荐用于生产环境) + +```bash +STORAGE_SERVICE="r2" +STORAGE_SETTINGS='{ + "r2_account_id": "your_cloudflare_account_id", + "r2_access_key_id": "your_r2_access_key_id", + "r2_secret_access_key": "your_r2_secret_access_key", + "r2_bucket_name": "your_bucket_name", + "r2_public_url_base": "https://your-custom-domain.com" +}' +``` + +### AWS S3 存储 + +```bash +STORAGE_SERVICE="s3" +STORAGE_SETTINGS='{ + "s3_access_key_id": "your_aws_access_key_id", + "s3_secret_access_key": "your_aws_secret_access_key", + "s3_bucket_name": "your_s3_bucket_name", + "s3_region_name": "us-east-1", + "s3_public_url_base": "https://your-custom-domain.com" +}' +``` +""", + } + }, + ) # 数据库设置 - mysql_host: str = "localhost" - mysql_port: int = 3306 - mysql_database: str = "osu_api" - mysql_user: str = "osu_api" - mysql_password: str = "password" - mysql_root_password: str = "password" - redis_url: str = "redis://127.0.0.1:6379/0" + mysql_host: Annotated[ + str, + Field(default="localhost", description="MySQL 服务器地址"), + "数据库设置", + ] + mysql_port: Annotated[ + int, + Field(default=3306, description="MySQL 服务器端口"), + "数据库设置", + ] + mysql_database: Annotated[ + str, + Field(default="osu_api", description="MySQL 数据库名称"), + "数据库设置", + ] + mysql_user: Annotated[ + str, + Field(default="osu_api", description="MySQL 用户名"), + "数据库设置", + ] + mysql_password: Annotated[ + str, + Field(default="password", description="MySQL 密码"), + "数据库设置", + ] + mysql_root_password: Annotated[ + str, + Field(default="password", description="MySQL root 密码"), + "数据库设置", + ] + redis_url: Annotated[ + str, + Field(default="redis://127.0.0.1:6379/0", description="Redis 连接 URL"), + "数据库设置", + ] @property def database_url(self) -> str: return f"mysql+aiomysql://{self.mysql_user}:{self.mysql_password}@{self.mysql_host}:{self.mysql_port}/{self.mysql_database}" # JWT 设置 - secret_key: str = Field(default="your_jwt_secret_here", alias="jwt_secret_key") - algorithm: str = "HS256" - access_token_expire_minutes: int = 1440 - jwt_audience: str = "5" - jwt_issuer: str | None = None + secret_key: Annotated[ + str, + Field( + default="your_jwt_secret_here", + alias="jwt_secret_key", + description="JWT 签名密钥", + ), + "JWT 设置", + ] + algorithm: Annotated[ + str, + Field(default="HS256", alias="jwt_algorithm", description="JWT 算法"), + "JWT 设置", + ] + access_token_expire_minutes: Annotated[ + int, + Field(default=1440, description="访问令牌过期时间(分钟)"), + "JWT 设置", + ] + jwt_audience: Annotated[ + str, + Field(default="5", description="JWT 受众"), + "JWT 设置", + ] + jwt_issuer: Annotated[ + str | None, + Field(default=None, description="JWT 签发者"), + "JWT 设置", + ] # OAuth 设置 - osu_client_id: int = 5 - osu_client_secret: str = "FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk" - osu_web_client_id: int = 6 - osu_web_client_secret: str = "your_osu_web_client_secret_here" + osu_client_id: Annotated[ + int, + Field(default=5, description="OAuth 客户端 ID"), + "OAuth 设置", + ] + osu_client_secret: Annotated[ + str, + Field( + default="FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk", + description="OAuth 客户端密钥", + ), + "OAuth 设置", + ] + osu_web_client_id: Annotated[ + int, + Field(default=6, description="Web OAuth 客户端 ID"), + "OAuth 设置", + ] + osu_web_client_secret: Annotated[ + str, + Field( + default="your_osu_web_client_secret_here", + description="Web OAuth 客户端密钥", + ), + "OAuth 设置", + ] # 服务器设置 - host: str = "0.0.0.0" - port: int = 8000 - debug: bool = False - cors_urls: list[HttpUrl] = [] - server_url: HttpUrl = HttpUrl("http://localhost:8000") - frontend_url: HttpUrl | None = None - enable_rate_limit: bool = True + host: Annotated[ + str, + Field(default="0.0.0.0", description="服务器监听地址"), + "服务器设置", + ] + port: Annotated[ + int, + Field(default=8000, description="服务器监听端口"), + "服务器设置", + ] + debug: Annotated[ + bool, + Field(default=False, description="是否启用调试模式"), + "服务器设置", + ] + cors_urls: Annotated[ + list[HttpUrl], + Field(default=[], description="额外的 CORS 允许的域名列表 (JSON 格式)"), + "服务器设置", + ] + server_url: Annotated[ + HttpUrl, + Field( + default=HttpUrl("http://localhost:8000"), + description="服务器 URL", + ), + "服务器设置", + ] + frontend_url: Annotated[ + HttpUrl | None, + Field( + default=None, + description="前端 URL,当访问从游戏打开的 URL 时会重定向到这个 URL,为空表示不重定向", + ), + "服务器设置", + ] + enable_rate_limit: Annotated[ + bool, + Field(default=True, description="是否启用速率限制"), + "服务器设置", + ] @property def web_url(self): @@ -105,93 +257,312 @@ class Settings(BaseSettings): return "/" # SignalR 设置 - signalr_negotiate_timeout: int = 30 - signalr_ping_interval: int = 15 + signalr_negotiate_timeout: Annotated[ + int, + Field(default=30, description="SignalR 协商超时时间(秒)"), + "SignalR 服务器设置", + ] + signalr_ping_interval: Annotated[ + int, + Field(default=15, description="SignalR ping 间隔(秒)"), + "SignalR 服务器设置", + ] # Fetcher 设置 - fetcher_client_id: str = "" - fetcher_client_secret: str = "" - fetcher_scopes: Annotated[list[str], NoDecode] = ["public"] + fetcher_client_id: Annotated[ + str, + Field(default="", description="Fetcher 客户端 ID"), + "Fetcher 设置", + ] + fetcher_client_secret: Annotated[ + str, + Field(default="", description="Fetcher 客户端密钥"), + "Fetcher 设置", + ] + fetcher_scopes: Annotated[ + list[str], + Field(default=["public"], description="Fetcher 权限范围,以逗号分隔每个权限"), + "Fetcher 设置", + NoDecode, + ] @property def fetcher_callback_url(self) -> str: return f"{self.server_url}fetcher/callback" # 日志设置 - log_level: str = "INFO" + log_level: Annotated[ + str, + Field(default="INFO", description="日志级别"), + "日志设置", + ] # 邮件服务设置 - enable_email_verification: bool = Field(default=False, description="是否启用邮件验证功能") - smtp_server: str = "localhost" - smtp_port: int = 587 - smtp_username: str = "" - smtp_password: str = "" - from_email: str = "noreply@example.com" - from_name: str = "osu! server" + enable_email_verification: Annotated[ + bool, + Field(default=False, description="是否启用邮件验证功能"), + "邮件服务设置", + ] + smtp_server: Annotated[ + str, + Field(default="localhost", description="SMTP 服务器地址"), + "邮件服务设置", + ] + smtp_port: Annotated[ + int, + Field(default=587, description="SMTP 服务器端口"), + "邮件服务设置", + ] + smtp_username: Annotated[ + str, + Field(default="", description="SMTP 用户名"), + "邮件服务设置", + ] + smtp_password: Annotated[ + str, + Field(default="", description="SMTP 密码"), + "邮件服务设置", + ] + from_email: Annotated[ + str, + Field(default="noreply@example.com", description="发件人邮箱"), + "邮件服务设置", + ] + from_name: Annotated[ + str, + Field(default="osu! server", description="发件人名称"), + "邮件服务设置", + ] - # Sentry 配置 - sentry_dsn: HttpUrl | None = None - - # New Relic 配置 - new_relic_environment: None | str = None + # 监控配置 + sentry_dsn: Annotated[ + HttpUrl | None, + Field(default=None, description="Sentry DSN,为空不启用 Sentry"), + "监控设置", + ] + new_relic_environment: Annotated[ + str | None, + Field(default=None, description='New Relic 环境标识,设置为 "production" 或 "development"'), + "监控设置", + ] # GeoIP 配置 - maxmind_license_key: str = "" - geoip_dest_dir: str = "./geoip" - geoip_update_day: int = 1 # 每周更新的星期几(0=周一,6=周日) - geoip_update_hour: int = 2 # 每周更新的小时数(0-23) + maxmind_license_key: Annotated[ + str, + Field(default="", description="MaxMind License Key(用于下载离线IP库)"), + "GeoIP 配置", + ] + geoip_dest_dir: Annotated[ + str, + Field(default="./geoip", description="GeoIP 数据库存储目录"), + "GeoIP 配置", + ] + geoip_update_day: Annotated[ + int, + Field(default=1, description="GeoIP 每周更新的星期几(0=周一,6=周日)"), + "GeoIP 配置", + ] + geoip_update_hour: Annotated[ + int, + Field(default=2, description="GeoIP 每周更新时间(小时,0-23)"), + "GeoIP 配置", + ] # 游戏设置 - enable_rx: bool = Field(default=False, validation_alias=AliasChoices("enable_rx", "enable_osu_rx")) - enable_ap: bool = Field(default=False, validation_alias=AliasChoices("enable_ap", "enable_osu_ap")) - enable_all_mods_pp: bool = False - enable_supporter_for_all_users: bool = False - enable_all_beatmap_leaderboard: bool = False - enable_all_beatmap_pp: bool = False - seasonal_backgrounds: Annotated[list[str], BeforeValidator(_parse_list)] = [] - beatmap_tag_top_count: int = 2 # this is 10 in osu-web + enable_rx: Annotated[ + bool, + Field( + default=False, + validation_alias=AliasChoices("enable_rx", "enable_osu_rx"), + description="启用 RX mod 统计数据", + ), + "游戏设置", + ] + enable_ap: Annotated[ + bool, + Field( + default=False, + validation_alias=AliasChoices("enable_ap", "enable_osu_ap"), + description="启用 AP mod 统计数据", + ), + "游戏设置", + ] + enable_all_mods_pp: Annotated[ + bool, + Field(default=False, description="启用所有 Mod 的 PP 计算"), + "游戏设置", + ] + enable_supporter_for_all_users: Annotated[ + bool, + Field(default=False, description="启用所有新注册用户的支持者状态"), + "游戏设置", + ] + enable_all_beatmap_leaderboard: Annotated[ + bool, + Field(default=False, description="启用所有谱面的排行榜"), + "游戏设置", + ] + enable_all_beatmap_pp: Annotated[ + bool, + Field(default=False, description="允许任何谱面获得 PP"), + "游戏设置", + ] + seasonal_backgrounds: Annotated[ + list[str], + Field(default=[], description="季节背景图 URL 列表"), + "游戏设置", + ] + beatmap_tag_top_count: Annotated[ + int, + Field(default=2, description="显示在结算列表的标签所需的最低票数"), + "游戏设置", + ] # 谱面缓存设置 - enable_beatmap_preload: bool = True - beatmap_cache_expire_hours: int = 24 + enable_beatmap_preload: Annotated[ + bool, + Field(default=True, description="启用谱面缓存预加载"), + "缓存设置", + "谱面缓存", + ] + beatmap_cache_expire_hours: Annotated[ + int, + Field(default=24, description="谱面缓存过期时间(小时)"), + "缓存设置", + "谱面缓存", + ] # 排行榜缓存设置 - enable_ranking_cache: bool = True - ranking_cache_expire_minutes: int = 10 # 排行榜缓存过期时间(分钟) - ranking_cache_refresh_interval_minutes: int = 10 # 排行榜缓存刷新间隔(分钟) - ranking_cache_max_pages: int = 20 # 最多缓存的页数 - ranking_cache_top_countries: int = 20 # 缓存前N个国家的排行榜 + enable_ranking_cache: Annotated[ + bool, + Field(default=True, description="启用排行榜缓存"), + "缓存设置", + "排行榜缓存", + ] + ranking_cache_expire_minutes: Annotated[ + int, + Field(default=10, description="排行榜缓存过期时间(分钟)"), + "缓存设置", + "排行榜缓存", + ] + ranking_cache_refresh_interval_minutes: Annotated[ + int, + Field(default=10, description="排行榜缓存刷新间隔(分钟)"), + "缓存设置", + "排行榜缓存", + ] + ranking_cache_max_pages: Annotated[ + int, + Field(default=20, description="最多缓存的页数"), + "缓存设置", + "排行榜缓存", + ] + ranking_cache_top_countries: Annotated[ + int, + Field(default=20, description="缓存前N个国家的排行榜"), + "缓存设置", + "排行榜缓存", + ] # 用户缓存设置 - enable_user_cache_preload: bool = True # 启用用户缓存预加载 - user_cache_expire_seconds: int = 300 # 用户信息缓存过期时间(秒) - user_scores_cache_expire_seconds: int = 60 # 用户成绩缓存过期时间(秒) - user_beatmapsets_cache_expire_seconds: int = 600 # 用户谱面集缓存过期时间(秒) - user_cache_max_preload_users: int = 200 # 最多预加载的用户数量 - user_cache_concurrent_limit: int = 10 # 并发缓存用户的限制 + enable_user_cache_preload: Annotated[ + bool, + Field(default=True, description="启用用户缓存预加载"), + "缓存设置", + "用户缓存", + ] + user_cache_expire_seconds: Annotated[ + int, + Field(default=300, description="用户信息缓存过期时间(秒)"), + "缓存设置", + "用户缓存", + ] + user_scores_cache_expire_seconds: Annotated[ + int, + Field(default=60, description="用户成绩缓存过期时间(秒)"), + "缓存设置", + "用户缓存", + ] + user_beatmapsets_cache_expire_seconds: Annotated[ + int, + Field(default=600, description="用户谱面集缓存过期时间(秒)"), + "缓存设置", + "用户缓存", + ] + user_cache_max_preload_users: Annotated[ + int, + Field(default=200, description="最多预加载的用户数量"), + "缓存设置", + "用户缓存", + ] + user_cache_concurrent_limit: Annotated[ + int, + Field(default=10, description="并发缓存用户的限制"), + "缓存设置", + "用户缓存", + ] # 资源代理设置 - enable_asset_proxy: bool = True # 启用资源代理功能 - custom_asset_domain: str = "g0v0.top" # 自定义资源域名 - asset_proxy_prefix: str = "assets-ppy" # assets.ppy.sh的自定义前缀 - avatar_proxy_prefix: str = "a-ppy" # a.ppy.sh的自定义前缀 - beatmap_proxy_prefix: str = "b-ppy" # b.ppy.sh的自定义前缀 + enable_asset_proxy: Annotated[ + bool, + Field(default=False, description="启用资源代理"), + "资源代理设置", + ] + custom_asset_domain: Annotated[ + str, + Field(default="g0v0.top", description="自定义资源域名"), + "资源代理设置", + ] + asset_proxy_prefix: Annotated[ + str, + Field(default="assets-ppy", description="assets.ppy.sh 的自定义前缀"), + "资源代理设置", + ] + avatar_proxy_prefix: Annotated[ + str, + Field(default="a-ppy", description="a.ppy.sh 的自定义前缀"), + "资源代理设置", + ] + beatmap_proxy_prefix: Annotated[ + str, + Field(default="b-ppy", description="b.ppy.sh 的自定义前缀"), + "资源代理设置", + ] # 反作弊设置 - suspicious_score_check: bool = True - banned_name: list[str] = [ - "mrekk", - "vaxei", - "btmc", - "cookiezi", - "peppy", - "saragi", - "chocomint", + suspicious_score_check: Annotated[ + bool, + Field(default=True, description="启用可疑分数检查(star>25&acc<80 或 pp>3000)"), + "反作弊设置", + ] + banned_name: Annotated[ + list[str], + Field( + default=[ + "mrekk", + "vaxei", + "btmc", + "cookiezi", + "peppy", + "saragi", + "chocomint", + ], + description="禁止使用的用户名列表", + ), + "反作弊设置", ] # 存储设置 - storage_service: StorageServiceType = StorageServiceType.LOCAL - storage_settings: LocalStorageSettings | CloudflareR2Settings | AWSS3StorageSettings = LocalStorageSettings() + storage_service: Annotated[ + StorageServiceType, + Field(default=StorageServiceType.LOCAL, description="存储服务类型:local、r2、s3"), + "存储服务设置", + ] + storage_settings: Annotated[ + LocalStorageSettings | CloudflareR2Settings | AWSS3StorageSettings, + Field(default=LocalStorageSettings(), description="存储服务配置 (JSON 格式)"), + "存储服务设置", + ] @field_validator("fetcher_scopes", mode="before") def validate_fetcher_scopes(cls, v: Any) -> list[str]: @@ -217,4 +588,4 @@ class Settings(BaseSettings): return v -settings = Settings() +settings = Settings() # pyright: ignore[reportCallIssue]