├── .env.example ├── .env.example.mitm-only ├── .github └── workflows │ ├── backend.yml │ ├── docs.yml │ └── frontend.yml ├── .gitignore ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── alembic.ini ├── alembic ├── README ├── env.py ├── script.py.mako └── versions │ ├── 2bd0f955d1d3_add_api_token.py │ └── 6cd62168a5f4_init_database.py ├── docs ├── .gitignore ├── .vitepress │ └── config.mts ├── begin.md ├── development.md ├── implementation.md ├── index.md ├── package-lock.json ├── package.json ├── privacy-policy.md ├── proxies │ ├── clash.md │ ├── quantx.md │ ├── rocket.md │ └── singbox.md ├── public │ ├── UsagiPass.conf │ ├── UsagiPass.json │ ├── UsagiPass.yaml │ └── favicon.ico ├── terms-of-use.md └── thanks.md ├── poetry.lock ├── poetry.toml ├── pyproject.toml ├── usagipass ├── .gitignore ├── app │ ├── api │ │ ├── __init__.py │ │ ├── accounts.py │ │ ├── announcements.py │ │ ├── images.py │ │ ├── servers.py │ │ ├── users.py │ │ └── v1 │ │ │ ├── __init__.py │ │ │ └── accounts.py │ ├── database.py │ ├── entrypoint.py │ ├── logging.py │ ├── models.py │ ├── settings.py │ └── usecases │ │ ├── __init__.py │ │ ├── accounts.py │ │ ├── addons.py │ │ ├── authorize.py │ │ ├── crawler.py │ │ └── maimai.py ├── main.py └── tools │ └── importer.py └── web ├── .gitignore ├── .vscode └── extensions.json ├── README.md ├── env.d.ts ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── public └── favicon.ico ├── src ├── App.vue ├── assets │ ├── SEGAMaruGothicDB.woff2 │ ├── base.css │ ├── logo.svg │ ├── main.css │ ├── misc │ │ ├── UI_CMN_Name_DX.png │ │ ├── UI_CardBase_Back.png │ │ ├── afdian.svg │ │ ├── avatar.webp │ │ ├── github-mark-white.svg │ │ ├── images.svg │ │ ├── logout.svg │ │ ├── refresh.svg │ │ └── settings.svg │ └── rating │ │ ├── UI_CMA_Rating_Base_0.png │ │ ├── UI_CMA_Rating_Base_1.png │ │ ├── UI_CMA_Rating_Base_10.png │ │ ├── UI_CMA_Rating_Base_2.png │ │ ├── UI_CMA_Rating_Base_3.png │ │ ├── UI_CMA_Rating_Base_4.png │ │ ├── UI_CMA_Rating_Base_5.png │ │ ├── UI_CMA_Rating_Base_6.png │ │ ├── UI_CMA_Rating_Base_7.png │ │ ├── UI_CMA_Rating_Base_8.png │ │ ├── UI_CMA_Rating_Base_9.png │ │ └── num │ │ ├── UI_CMN_Num_26p_0.png │ │ ├── UI_CMN_Num_26p_1.png │ │ ├── UI_CMN_Num_26p_10.png │ │ ├── UI_CMN_Num_26p_2.png │ │ ├── UI_CMN_Num_26p_3.png │ │ ├── UI_CMN_Num_26p_4.png │ │ ├── UI_CMN_Num_26p_5.png │ │ ├── UI_CMN_Num_26p_6.png │ │ ├── UI_CMN_Num_26p_7.png │ │ ├── UI_CMN_Num_26p_8.png │ │ └── UI_CMN_Num_26p_9.png ├── components │ ├── CardBack.vue │ ├── CharaInfo.vue │ ├── DXRating.vue │ ├── PlayerInfo.vue │ ├── QRCode.vue │ ├── admin │ │ └── AdminAnnouncements.vue │ ├── menus │ │ ├── Bind.vue │ │ ├── Gallery.vue │ │ ├── Login.vue │ │ ├── PreferencesPass.vue │ │ └── Update.vue │ └── widgets │ │ ├── AnnouncementModal.vue │ │ ├── Notification.vue │ │ ├── Prompt.vue │ │ └── TermsLink.vue ├── main.ts ├── router │ └── index.ts ├── stores │ ├── announcement.ts │ ├── image.ts │ ├── notification.ts │ ├── server.ts │ └── user.ts ├── types.ts └── views │ ├── AdminView.vue │ ├── CropperView.vue │ ├── DXBaseView.vue │ ├── DXPassView.vue │ └── MenuView.vue ├── tailwind.config.js ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.env.example: -------------------------------------------------------------------------------- 1 | # Frontend 2 | VITE_URL="http://127.0.0.1:8000" 3 | VITE_DOCS="https://dxpass.turou.fun" 4 | 5 | # Backend 6 | APP_HOST="127.0.0.1" 7 | APP_PORT=8000 8 | APP_ROOT="/api" 9 | APP_URL="http://localhost:5173/" 10 | 11 | MITM_HOST="0.0.0.0" 12 | MITM_PORT=2560 13 | 14 | MYSQL_URL="your_mysql_url_here" 15 | REDIS_URL="redis://localhost:6379/0" 16 | JWT_SECRET="change_me_in_production_enviroment" 17 | HTTPX_PROXY= 18 | ARCADE_PROXY= 19 | 20 | LXNS_DEVELOPER_TOKEN="developer_token_here" 21 | DIVINGFISH_DEVELOPER_TOKEN="developer_token_here" 22 | 23 | DEFAULT_CHARACTER="default" 24 | DEFAULT_BACKGROUND="default" 25 | DEFAULT_FRAME="default" 26 | DEFAULT_PASSNAME="default" 27 | -------------------------------------------------------------------------------- /.env.example.mitm-only: -------------------------------------------------------------------------------- 1 | # Frontend 2 | VITE_URL="http://127.0.0.1:8000" 3 | VITE_DOCS="https://dxpass.turou.fun" 4 | 5 | # Backend 6 | APP_HOST="127.0.0.1" 7 | APP_PORT=8000 8 | APP_ROOT="/api" 9 | APP_URL="https://up.turou.fun/" 10 | 11 | MITM_HOST="0.0.0.0" 12 | MITM_PORT=2560 13 | 14 | MYSQL_URL="mysql+pymysql://root:password@localhost:3306/usagipass" 15 | REDIS_URL="redis://localhost:6379/0" 16 | JWT_SECRET="change_me_in_production_enviroment" 17 | HTTPX_PROXY= 18 | ARCADE_PROXY= 19 | 20 | LXNS_DEVELOPER_TOKEN="developer_token_here" 21 | DIVINGFISH_DEVELOPER_TOKEN="developer_token_here" 22 | 23 | DEFAULT_CHARACTER="default" 24 | DEFAULT_BACKGROUND="default" 25 | DEFAULT_FRAME="default" 26 | DEFAULT_PASSNAME="default" 27 | -------------------------------------------------------------------------------- /.github/workflows/backend.yml: -------------------------------------------------------------------------------- 1 | name: UsagiPass Backend CI 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | paths: 9 | - "pyproject.toml" # upstream has been bumped 10 | - "usagipass/**" # backend module has been updated 11 | - "alembic/**" # database migration has been updated 12 | 13 | jobs: 14 | backend: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Fetch the repository 18 | uses: actions/checkout@v4 19 | 20 | - name: Deploy to Server 21 | uses: easingthemes/ssh-deploy@v5.1.0 22 | with: 23 | SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} 24 | SCRIPT_AFTER: "cd /UsagiPass && poetry install && pm2 restart usagipass" 25 | ARGS: '-rlgoDzvc -i --delete --exclude=".data" --exclude=".venv" --exclude=".env"' 26 | SOURCE: "/" 27 | REMOTE_HOST: ${{ secrets.REMOTE_HOST }} 28 | REMOTE_USER: ${{ secrets.REMOTE_USER }} 29 | TARGET: /UsagiPass 30 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: UsagiPass Docs CI 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | paths: 9 | - "docs/**" # docs has been updated 10 | 11 | jobs: 12 | docs: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Fetch the repository 16 | uses: actions/checkout@v4 17 | 18 | - name: Use Node.js 20 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: 20 22 | 23 | - name: Install npm dependencies 24 | working-directory: ./docs 25 | run: npm install 26 | 27 | - name: Run build task 28 | working-directory: ./docs 29 | run: npm run docs:build 30 | 31 | - name: Deploy to Server 32 | uses: easingthemes/ssh-deploy@v5.1.0 33 | with: 34 | SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} 35 | ARGS: "-rlgoDzvc -i --delete" 36 | SOURCE: "docs/.vitepress/dist/" 37 | REMOTE_HOST: ${{ secrets.REMOTE_HOST }} 38 | REMOTE_USER: ${{ secrets.REMOTE_USER }} 39 | TARGET: ${{ secrets.REMOTE_TARGET }}/dxpass.turou.fun 40 | -------------------------------------------------------------------------------- /.github/workflows/frontend.yml: -------------------------------------------------------------------------------- 1 | name: UsagiPass Frontend CI 2 | 3 | env: 4 | VITE_URL: https://up.turou.fun/api 5 | VITE_DOCS: https://dxpass.turou.fun 6 | 7 | on: 8 | workflow_dispatch: 9 | push: 10 | branches: 11 | - main 12 | paths: 13 | - "web/**" # frontend module has been updated 14 | 15 | jobs: 16 | frontend: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Fetch the repository 20 | uses: actions/checkout@v4 21 | 22 | - name: Use Node.js 20 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: 20 26 | 27 | - name: Install npm dependencies 28 | working-directory: ./web 29 | run: npm install 30 | 31 | - name: Run build task 32 | working-directory: ./web 33 | run: npm run build 34 | 35 | - name: Deploy to Server 36 | uses: easingthemes/ssh-deploy@v5.1.0 37 | with: 38 | SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} 39 | ARGS: "-rlgoDzvc -i --delete" 40 | SOURCE: "web/dist/" 41 | REMOTE_HOST: ${{ secrets.REMOTE_HOST }} 42 | REMOTE_USER: ${{ secrets.REMOTE_USER }} 43 | TARGET: ${{ secrets.REMOTE_TARGET }}/up.turou.fun 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .data 3 | .venv 4 | .env 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "name": "Run Backend", 5 | "type": "debugpy", 6 | "request": "launch", 7 | "program": "${workspaceFolder}/usagipass/main.py", 8 | "cwd": "${workspaceFolder}", 9 | "console": "integratedTerminal", 10 | "justMyCode": false, 11 | "env": { 12 | "PYDEVD_DISABLE_FILE_VALIDATION": "1" 13 | } 14 | }, 15 | { 16 | "name": "Run Frontend", 17 | "command": "npm run dev", 18 | "request": "launch", 19 | "type": "node-terminal", 20 | "cwd": "${workspaceFolder}/web" 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # UsagiPass 2 | 3 |
4 | UsagiPass Logo 5 |

动态生成可登录的 DXPASS

6 |

从零开始美化你的玩家二维码

7 |
8 | 9 | ## 什么是 UsagiPass? 10 | 11 | DXPass 是**日服限定**的,可以在制卡机上制作的具有特殊功能的实体卡片。通常印有玩家信息、角色立绘、区域背景等元素,有提升跑图距离、解锁上位难度的谱面等功能。 12 | 13 | 虽然国服无法制作 DXPass,但是独特的微信登录机制使我们有了动态生成 DXPass 的可能,UsagiPass 应运而生。 14 | 15 | ## ✨ 主要功能 16 | 17 | - **动态生成**: 基于水鱼/落雪查分器的数据动态生成玩家的 DXPASS,用户个性化数据云端储存 18 | - **高度自定义**: 自定义 DXPASS 背景、外框、角色,支持使用预设素材或个性化上传自己的图片 19 | - **支持登录**: 通过代理直接嵌入微信玩家二维码页面,可直接扫描 DXPASS 上机,逼格满满 20 | - **更新查分器**: 支持一键更新水鱼/落雪查分器,无需复杂的配置 21 | 22 | ## 📦 安装使用 23 | 24 | ### 安装步骤 25 | 26 | 1. **安装代理软件**: 27 | - 安卓: [**Clash(推荐)**](https://dxpass.turou.fun/proxies/clash.html),[Sing-box](https://dxpass.turou.fun/proxies/singbox.html) 28 | - iOS: [**Shadowrocket(推荐)**](https://dxpass.turou.fun/proxies/rocket.html),[Sing-box](https://dxpass.turou.fun/proxies/singbox.html),[QuantX](https://dxpass.turou.fun/proxies/quantx.html) 29 | 30 | 2. **启动代理**:确保代理软件在手机后台运行中 31 | 32 | 3. **打开微信二维码**:在微信中打开舞萌或中二公众号的**登入二维码**,将自动跳转至 UsagiPass 登录界面 33 | 34 | 4. **登录使用**:使用水鱼或落雪查分器账号进行登录,登录后即可进入 UsagiPass 主页 35 | 36 | ### 使用方法 37 | 38 | 1. 登录后将显示默认个性化配置,点击画面右下方的齿轮图标进入设置页面 39 | 2. 在设置中按照喜好调整个性化配置,切换背景、角色、边框等资源 40 | 3. 请初次使用的玩家在设置中粘贴自己的好友代码(可在舞萌DX-好友页面查询) 41 | 4. 修改完成后点击保存按钮应用设置 42 | 43 | ## 🔧 技术实现 44 | 45 | UsagiPass 利用中间人代理(MITM)技术修改华立服务器的流量: 46 | 47 | 1. 通过代理替换 sys-all.cn 网页,将请求重定向到 dxpass.turou.fun 48 | 2. 重定向时携带原网页中的查询参数(SGWCMAID),在前端以 JS 方式绘制二维码 49 | 3. 支持更新查分器功能,原理类似 Bakapiano 方案,在适当时机转发 tgk-wcaime.wahlap.com 地址 50 | 51 | ## 🛠️ 开发者部署 52 | 53 | ### 前置环境 54 | 55 | - 合适的 Linux 发行版(以 Debian 为例) 56 | - 能够连接至 GitHub DivingFish LXNS 的网络环境 57 | - Python 3.12+ 58 | 59 | ### 部署方式 60 | 61 | - Python 3.12 以上版本 62 | - 在 项目根目录 下创建 `.env` 文件, 可以根据 `.env.example` 模板文件进行配置 63 | - 执行 `pip install poetry` 全局安装 Poetry 64 | - 执行 `poetry install` 安装项目依赖 65 | - 执行 `poetry run app` 运行项目 66 | 67 | > 更新项目: `git pull && poetry install && poetry run app` 68 | 69 | ## 🤔 常见问题 70 | 71 | **Q: 我不清楚UsagiPass的某某功能如何使用** 72 | 73 | A: 可以在左侧的目录(手机用户请点击左上角Menu按钮)中找到部分功能的详细介绍。如果仍有疑问,可以加群进行询问。 74 | 75 | **Q: IOS 使用小火箭需要购买** 76 | 77 | A: 可以使用 Sing-box 方案,Sing-box 是一款在外区商店可以免费下载的代理软件。 78 | 79 | **Q: IOS 我不知道应该如何获得外区账号** 80 | 81 | A: 可以在B站搜索相关关键词,这类视频还是挺多的,请尽量不要在群内讨论相关话题。 82 | 83 | ## 🤝 支持项目 84 | 85 | 如果觉得 UsagiPass 好用的话,不妨给我们的仓库点一个 ⭐! 86 | 87 | 我们也开放了爱发电入口,如果您愿意 [赞助 UsagiPass](https://afdian.com/a/turou),我们会在特别感谢中提到您的名字。 88 | 89 | ## 📱 联系我们 90 | 91 | - **用户群**: 363346002 92 | 93 | --- 94 | 95 | Copyright © 2019-2024 TuRou -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | # Use forward slashes (/) also on windows to provide an os agnostic path 6 | script_location = alembic 7 | 8 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 9 | # Uncomment the line below if you want the files to be prepended with date and time 10 | # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file 11 | # for all available tokens 12 | # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 13 | 14 | # sys.path path, will be prepended to sys.path if present. 15 | # defaults to the current working directory. 16 | prepend_sys_path = . 17 | 18 | # timezone to use when rendering the date within the migration file 19 | # as well as the filename. 20 | # If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. 21 | # Any required deps can installed by adding `alembic[tz]` to the pip requirements 22 | # string value is passed to ZoneInfo() 23 | # leave blank for localtime 24 | # timezone = 25 | 26 | # max length of characters to apply to the "slug" field 27 | # truncate_slug_length = 40 28 | 29 | # set to 'true' to run the environment during 30 | # the 'revision' command, regardless of autogenerate 31 | # revision_environment = false 32 | 33 | # set to 'true' to allow .pyc and .pyo files without 34 | # a source .py file to be detected as revisions in the 35 | # versions/ directory 36 | # sourceless = false 37 | 38 | # version location specification; This defaults 39 | # to alembic/versions. When using multiple version 40 | # directories, initial revisions must be specified with --version-path. 41 | # The path separator used here should be the separator specified by "version_path_separator" below. 42 | # version_locations = %(here)s/bar:%(here)s/bat:alembic/versions 43 | 44 | # version path separator; As mentioned above, this is the character used to split 45 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 46 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 47 | # Valid values for version_path_separator are: 48 | # 49 | # version_path_separator = : 50 | # version_path_separator = ; 51 | # version_path_separator = space 52 | # version_path_separator = newline 53 | # 54 | # Use os.pathsep. Default configuration used for new projects. 55 | version_path_separator = os 56 | 57 | # set to 'true' to search source files recursively 58 | # in each "version_locations" directory 59 | # new in Alembic version 1.10 60 | # recursive_version_locations = false 61 | 62 | # the output encoding used when revision files 63 | # are written from script.py.mako 64 | # output_encoding = utf-8 65 | 66 | sqlalchemy.url = driver://user:pass@localhost/dbname 67 | 68 | 69 | [post_write_hooks] 70 | # post_write_hooks defines scripts or Python functions that are run 71 | # on newly generated revision scripts. See the documentation for further 72 | # detail and examples 73 | 74 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 75 | # hooks = black 76 | # black.type = console_scripts 77 | # black.entrypoint = black 78 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 79 | 80 | # lint with attempts to fix using "ruff" - use the exec runner, execute a binary 81 | # hooks = ruff 82 | # ruff.type = exec 83 | # ruff.executable = %(here)s/.venv/bin/ruff 84 | # ruff.options = --fix REVISION_SCRIPT_FILENAME 85 | 86 | # Logging configuration 87 | [loggers] 88 | keys = root,sqlalchemy,alembic 89 | 90 | [handlers] 91 | keys = console 92 | 93 | [formatters] 94 | keys = generic 95 | 96 | [logger_root] 97 | level = WARNING 98 | handlers = console 99 | qualname = 100 | 101 | [logger_sqlalchemy] 102 | level = WARNING 103 | handlers = 104 | qualname = sqlalchemy.engine 105 | 106 | [logger_alembic] 107 | level = INFO 108 | handlers = 109 | qualname = alembic 110 | 111 | [handler_console] 112 | class = StreamHandler 113 | args = (sys.stderr,) 114 | level = NOTSET 115 | formatter = generic 116 | 117 | [formatter_generic] 118 | format = %(levelname)-5.5s [%(name)s] %(message)s 119 | datefmt = %H:%M:%S 120 | -------------------------------------------------------------------------------- /alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /alembic/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | from sqlalchemy import engine_from_config 3 | from sqlalchemy import pool 4 | from alembic import context 5 | 6 | from usagipass.app.models import SQLModel as SQLModelScope 7 | from usagipass.app.settings import mysql_url 8 | 9 | # this is the Alembic Config object, which provides 10 | # access to the values within the .ini file in use. 11 | config = context.config 12 | 13 | # Interpret the config file for Python logging. 14 | # This line sets up loggers basically. 15 | if config.config_file_name is not None: 16 | fileConfig(config.config_file_name) 17 | 18 | # add your model's MetaData object here 19 | # for 'autogenerate' support 20 | # from myapp import mymodel 21 | # target_metadata = mymodel.Base.metadata 22 | target_metadata = SQLModelScope.metadata 23 | config.set_main_option("sqlalchemy.url", mysql_url) 24 | 25 | # other values from the config, defined by the needs of env.py, 26 | # can be acquired: 27 | # my_important_option = config.get_main_option("my_important_option") 28 | # ... etc. 29 | 30 | 31 | def run_migrations_offline() -> None: 32 | """Run migrations in 'offline' mode. 33 | 34 | This configures the context with just a URL 35 | and not an Engine, though an Engine is acceptable 36 | here as well. By skipping the Engine creation 37 | we don't even need a DBAPI to be available. 38 | 39 | Calls to context.execute() here emit the given string to the 40 | script output. 41 | 42 | """ 43 | url = config.get_main_option("sqlalchemy.url") 44 | context.configure( 45 | url=url, 46 | target_metadata=target_metadata, 47 | literal_binds=True, 48 | dialect_opts={"paramstyle": "named"}, 49 | ) 50 | 51 | with context.begin_transaction(): 52 | context.run_migrations() 53 | 54 | 55 | def run_migrations_online() -> None: 56 | """Run migrations in 'online' mode. 57 | 58 | In this scenario we need to create an Engine 59 | and associate a connection with the context. 60 | 61 | """ 62 | connectable = engine_from_config( 63 | config.get_section(config.config_ini_section, {}), 64 | prefix="sqlalchemy.", 65 | poolclass=pool.NullPool, 66 | ) 67 | 68 | with connectable.connect() as connection: 69 | context.configure(connection=connection, target_metadata=target_metadata) 70 | 71 | with context.begin_transaction(): 72 | context.run_migrations() 73 | 74 | 75 | if context.is_offline_mode(): 76 | run_migrations_offline() 77 | else: 78 | run_migrations_online() 79 | -------------------------------------------------------------------------------- /alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | import sqlmodel.sql.sqltypes 13 | ${imports if imports else ""} 14 | 15 | # revision identifiers, used by Alembic. 16 | revision: str = ${repr(up_revision)} 17 | down_revision: Union[str, None] = ${repr(down_revision)} 18 | branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} 19 | depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} 20 | 21 | 22 | def upgrade() -> None: 23 | ${upgrades if upgrades else "pass"} 24 | 25 | 26 | def downgrade() -> None: 27 | ${downgrades if downgrades else "pass"} 28 | -------------------------------------------------------------------------------- /alembic/versions/2bd0f955d1d3_add_api_token.py: -------------------------------------------------------------------------------- 1 | """add api token 2 | 3 | Revision ID: 2bd0f955d1d3 4 | Revises: 6cd62168a5f4 5 | Create Date: 2025-04-11 23:34:28.107336 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | import sqlmodel.sql.sqltypes 13 | 14 | 15 | # revision identifiers, used by Alembic. 16 | revision: str = '2bd0f955d1d3' 17 | down_revision: Union[str, None] = '6cd62168a5f4' 18 | branch_labels: Union[str, Sequence[str], None] = None 19 | depends_on: Union[str, Sequence[str], None] = None 20 | 21 | 22 | def upgrade() -> None: 23 | # ### commands auto generated by Alembic - please adjust! ### 24 | op.drop_constraint('fk_announcement_reads_announcement_id_announcements', 'announcement_reads', type_='foreignkey') 25 | op.drop_constraint('fk_announcement_reads_username_users', 'announcement_reads', type_='foreignkey') 26 | op.create_foreign_key(op.f('fk_announcement_reads_announcement_id_announcements'), 'announcement_reads', 'announcements', ['announcement_id'], ['id'], ondelete='CASCADE') 27 | op.create_foreign_key(op.f('fk_announcement_reads_username_users'), 'announcement_reads', 'users', ['username'], ['username'], ondelete='CASCADE') 28 | op.drop_constraint('fk_user_preferences_background_id_images', 'user_preferences', type_='foreignkey') 29 | op.drop_constraint('fk_user_preferences_frame_id_images', 'user_preferences', type_='foreignkey') 30 | op.drop_constraint('fk_user_preferences_passname_id_images', 'user_preferences', type_='foreignkey') 31 | op.drop_constraint('fk_user_preferences_username_users', 'user_preferences', type_='foreignkey') 32 | op.drop_constraint('fk_user_preferences_character_id_images', 'user_preferences', type_='foreignkey') 33 | op.create_foreign_key(op.f('fk_user_preferences_passname_id_images'), 'user_preferences', 'images', ['passname_id'], ['id'], ondelete='SET NULL') 34 | op.create_foreign_key(op.f('fk_user_preferences_username_users'), 'user_preferences', 'users', ['username'], ['username']) 35 | op.create_foreign_key(op.f('fk_user_preferences_frame_id_images'), 'user_preferences', 'images', ['frame_id'], ['id'], ondelete='SET NULL') 36 | op.create_foreign_key(op.f('fk_user_preferences_character_id_images'), 'user_preferences', 'images', ['character_id'], ['id'], ondelete='SET NULL') 37 | op.create_foreign_key(op.f('fk_user_preferences_background_id_images'), 'user_preferences', 'images', ['background_id'], ['id'], ondelete='SET NULL') 38 | op.add_column('users', sa.Column('api_token', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) 39 | op.create_index(op.f('ix_users_api_token'), 'users', ['api_token'], unique=True) 40 | # ### end Alembic commands ### 41 | 42 | 43 | def downgrade() -> None: 44 | # ### commands auto generated by Alembic - please adjust! ### 45 | op.drop_index(op.f('ix_users_api_token'), table_name='users') 46 | op.drop_column('users', 'api_token') 47 | op.drop_constraint(op.f('fk_user_preferences_background_id_images'), 'user_preferences', type_='foreignkey') 48 | op.drop_constraint(op.f('fk_user_preferences_character_id_images'), 'user_preferences', type_='foreignkey') 49 | op.drop_constraint(op.f('fk_user_preferences_frame_id_images'), 'user_preferences', type_='foreignkey') 50 | op.drop_constraint(op.f('fk_user_preferences_username_users'), 'user_preferences', type_='foreignkey') 51 | op.drop_constraint(op.f('fk_user_preferences_passname_id_images'), 'user_preferences', type_='foreignkey') 52 | op.create_foreign_key('fk_user_preferences_character_id_images', 'user_preferences', 'images', ['character_id'], ['id'], onupdate='RESTRICT', ondelete='SET NULL') 53 | op.create_foreign_key('fk_user_preferences_username_users', 'user_preferences', 'users', ['username'], ['username'], onupdate='RESTRICT', ondelete='RESTRICT') 54 | op.create_foreign_key('fk_user_preferences_passname_id_images', 'user_preferences', 'images', ['passname_id'], ['id'], onupdate='RESTRICT', ondelete='SET NULL') 55 | op.create_foreign_key('fk_user_preferences_frame_id_images', 'user_preferences', 'images', ['frame_id'], ['id'], onupdate='RESTRICT', ondelete='SET NULL') 56 | op.create_foreign_key('fk_user_preferences_background_id_images', 'user_preferences', 'images', ['background_id'], ['id'], onupdate='RESTRICT', ondelete='SET NULL') 57 | op.drop_constraint(op.f('fk_announcement_reads_username_users'), 'announcement_reads', type_='foreignkey') 58 | op.drop_constraint(op.f('fk_announcement_reads_announcement_id_announcements'), 'announcement_reads', type_='foreignkey') 59 | op.create_foreign_key('fk_announcement_reads_username_users', 'announcement_reads', 'users', ['username'], ['username'], onupdate='RESTRICT', ondelete='CASCADE') 60 | op.create_foreign_key('fk_announcement_reads_announcement_id_announcements', 'announcement_reads', 'announcements', ['announcement_id'], ['id'], onupdate='RESTRICT', ondelete='CASCADE') 61 | # ### end Alembic commands ### 62 | -------------------------------------------------------------------------------- /alembic/versions/6cd62168a5f4_init_database.py: -------------------------------------------------------------------------------- 1 | """init database 2 | 3 | Revision ID: 6cd62168a5f4 4 | Revises: 5 | Create Date: 2025-04-07 04:38:37.046419 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | from alembic import op 12 | import sqlalchemy as sa 13 | import sqlmodel.sql.sqltypes 14 | 15 | 16 | # revision identifiers, used by Alembic. 17 | revision: str = "6cd62168a5f4" 18 | down_revision: Union[str, None] = None 19 | branch_labels: Union[str, Sequence[str], None] = None 20 | depends_on: Union[str, Sequence[str], None] = None 21 | 22 | 23 | def upgrade() -> None: 24 | # ### commands auto generated by Alembic - please adjust! ### 25 | op.create_table( 26 | "announcements", 27 | sa.Column("id", sa.Integer(), nullable=False), 28 | sa.Column("title", sqlmodel.sql.sqltypes.AutoString(), nullable=False), 29 | sa.Column("content", sqlmodel.sql.sqltypes.AutoString(), nullable=False), 30 | sa.Column("is_active", sa.Boolean(), nullable=False), 31 | sa.Column("created_at", sa.DateTime(), nullable=False), 32 | sa.Column("updated_at", sa.DateTime(), nullable=False), 33 | sa.PrimaryKeyConstraint("id", name=op.f("pk_announcements")), 34 | ) 35 | op.create_index(op.f("ix_announcements_title"), "announcements", ["title"], unique=False) 36 | op.create_table( 37 | "images", 38 | sa.Column("id", sqlmodel.sql.sqltypes.AutoString(), nullable=False), 39 | sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), 40 | sa.Column("kind", sqlmodel.sql.sqltypes.AutoString(), nullable=False), 41 | sa.Column("sega_name", sqlmodel.sql.sqltypes.AutoString(), nullable=True), 42 | sa.Column("uploaded_by", sqlmodel.sql.sqltypes.AutoString(), nullable=True), 43 | sa.Column("uploaded_at", sa.DateTime(), nullable=False), 44 | sa.PrimaryKeyConstraint("id", name=op.f("pk_images")), 45 | ) 46 | op.create_index(op.f("ix_images_sega_name"), "images", ["sega_name"], unique=False) 47 | op.create_table( 48 | "user_accounts", 49 | sa.Column("account_name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), 50 | sa.Column("account_server", sa.Enum("DIVING_FISH", "LXNS", "WECHAT", name="accountserver"), nullable=False), 51 | sa.Column("account_password", sqlmodel.sql.sqltypes.AutoString(), nullable=False), 52 | sa.Column("nickname", sqlmodel.sql.sqltypes.AutoString(), nullable=False), 53 | sa.Column("bind_qq", sqlmodel.sql.sqltypes.AutoString(), nullable=False), 54 | sa.Column("player_rating", sa.Integer(), nullable=False), 55 | sa.Column("username", sqlmodel.sql.sqltypes.AutoString(), nullable=False), 56 | sa.Column("created_at", sa.DateTime(), nullable=False), 57 | sa.Column("updated_at", sa.DateTime(), nullable=False), 58 | sa.PrimaryKeyConstraint("account_name", "account_server", name=op.f("pk_user_accounts")), 59 | ) 60 | op.create_index(op.f("ix_user_accounts_username"), "user_accounts", ["username"], unique=False) 61 | op.create_table( 62 | "users", 63 | sa.Column("username", sqlmodel.sql.sqltypes.AutoString(), nullable=False), 64 | sa.Column("prefer_server", sa.Enum("DIVING_FISH", "LXNS", "WECHAT", name="accountserver"), nullable=False), 65 | sa.Column("privilege", sa.Enum("BANNED", "NORMAL", "ADMIN", name="privilege"), server_default="NORMAL", nullable=False), 66 | sa.Column("created_at", sa.DateTime(), nullable=False), 67 | sa.Column("updated_at", sa.DateTime(), nullable=False), 68 | sa.PrimaryKeyConstraint("username", name=op.f("pk_users")), 69 | ) 70 | op.create_table( 71 | "announcement_reads", 72 | sa.Column("id", sa.Integer(), nullable=False), 73 | sa.Column("announcement_id", sa.Integer(), nullable=False), 74 | sa.Column("username", sqlmodel.sql.sqltypes.AutoString(), nullable=False), 75 | sa.Column("read_at", sa.DateTime(), nullable=False), 76 | sa.ForeignKeyConstraint( 77 | ["announcement_id"], ["announcements.id"], name=op.f("fk_announcement_reads_announcement_id_announcements"), ondelete="CASCADE" 78 | ), 79 | sa.ForeignKeyConstraint(["username"], ["users.username"], name=op.f("fk_announcement_reads_username_users"), ondelete="CASCADE"), 80 | sa.PrimaryKeyConstraint("id", name=op.f("pk_announcement_reads")), 81 | ) 82 | op.create_index(op.f("ix_announcement_reads_announcement_id"), "announcement_reads", ["announcement_id"], unique=False) 83 | op.create_index(op.f("ix_announcement_reads_username"), "announcement_reads", ["username"], unique=False) 84 | op.create_table( 85 | "user_preferences", 86 | sa.Column("maimai_version", sqlmodel.sql.sqltypes.AutoString(), nullable=True), 87 | sa.Column("simplified_code", sqlmodel.sql.sqltypes.AutoString(), nullable=True), 88 | sa.Column("character_name", sqlmodel.sql.sqltypes.AutoString(), nullable=True), 89 | sa.Column("friend_code", sqlmodel.sql.sqltypes.AutoString(), nullable=True), 90 | sa.Column("display_name", sqlmodel.sql.sqltypes.AutoString(), nullable=True), 91 | sa.Column("dx_rating", sqlmodel.sql.sqltypes.AutoString(), nullable=True), 92 | sa.Column("qr_size", sa.Integer(), nullable=False), 93 | sa.Column("mask_type", sa.Integer(), nullable=False), 94 | sa.Column("chara_info_color", sqlmodel.sql.sqltypes.AutoString(), server_default="#fee37c", nullable=False), 95 | sa.Column("username", sqlmodel.sql.sqltypes.AutoString(), nullable=False), 96 | sa.Column("character_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True), 97 | sa.Column("background_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True), 98 | sa.Column("frame_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True), 99 | sa.Column("passname_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True), 100 | sa.ForeignKeyConstraint(["background_id"], ["images.id"], name=op.f("fk_user_preferences_background_id_images"), ondelete="SET NULL"), 101 | sa.ForeignKeyConstraint(["character_id"], ["images.id"], name=op.f("fk_user_preferences_character_id_images"), ondelete="SET NULL"), 102 | sa.ForeignKeyConstraint(["frame_id"], ["images.id"], name=op.f("fk_user_preferences_frame_id_images"), ondelete="SET NULL"), 103 | sa.ForeignKeyConstraint(["passname_id"], ["images.id"], name=op.f("fk_user_preferences_passname_id_images"), ondelete="SET NULL"), 104 | sa.ForeignKeyConstraint(["username"], ["users.username"], name=op.f("fk_user_preferences_username_users")), 105 | sa.PrimaryKeyConstraint("username", name=op.f("pk_user_preferences")), 106 | ) 107 | # ### end Alembic commands ### 108 | 109 | 110 | def downgrade() -> None: 111 | # ### commands auto generated by Alembic - please adjust! ### 112 | op.drop_table("user_preferences") 113 | op.drop_index(op.f("ix_announcement_reads_username"), table_name="announcement_reads") 114 | op.drop_index(op.f("ix_announcement_reads_announcement_id"), table_name="announcement_reads") 115 | op.drop_table("announcement_reads") 116 | op.drop_table("users") 117 | op.drop_index(op.f("ix_user_accounts_username"), table_name="user_accounts") 118 | op.drop_table("user_accounts") 119 | op.drop_index(op.f("ix_images_sega_name"), table_name="images") 120 | op.drop_table("images") 121 | op.drop_index(op.f("ix_announcements_title"), table_name="announcements") 122 | op.drop_table("announcements") 123 | # ### end Alembic commands ### 124 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | cache 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | pnpm-debug.log* 11 | lerna-debug.log* 12 | 13 | node_modules 14 | .DS_Store 15 | dist 16 | dist-ssr 17 | coverage 18 | *.local 19 | 20 | /cypress/videos/ 21 | /cypress/screenshots/ 22 | 23 | # Editor directories and files 24 | .vscode/* 25 | !.vscode/extensions.json 26 | .idea 27 | *.suo 28 | *.ntvs* 29 | *.njsproj 30 | *.sln 31 | *.sw? 32 | 33 | *.tsbuildinfo 34 | -------------------------------------------------------------------------------- /docs/.vitepress/config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress' 2 | 3 | // https://vitepress.dev/reference/site-config 4 | export default defineConfig({ 5 | lang: 'zh-CN', 6 | title: "UsagiPass", 7 | titleTemplate: ':title - UsagiPass', 8 | description: "UsagiPass 文档", 9 | themeConfig: { 10 | // https://vitepress.dev/reference/default-theme-config 11 | nav: [ 12 | { text: '主页', link: '/' }, 13 | { text: '手册', link: '/implementation' }, 14 | { text: '博客', link: 'https://turou.fun/' }, 15 | { text: '❤️', link: 'https://afdian.com/a/turou' }, 16 | ], 17 | 18 | sidebar: [ 19 | { 20 | text: '介绍', 21 | items: [ 22 | { text: '安装', link: '/begin' }, 23 | ] 24 | }, 25 | { 26 | text: '代理', 27 | items: [ 28 | { text: 'Clash', link: '/proxies/clash' }, 29 | { text: 'Shadowrocket', link: '/proxies/rocket' }, 30 | { text: 'Sing-box', link: '/proxies/singbox' }, 31 | { text: 'Quantumult X', link: '/proxies/quantx' }, 32 | ] 33 | }, 34 | { 35 | text: '手册', 36 | items: [ 37 | { text: '实现方式', link: '/implementation' }, 38 | { text: '参与开发', link: '/development' }, 39 | ] 40 | }, 41 | { 42 | text: '其他', 43 | items: [ 44 | { text: '隐私政策', link: '/privacy-policy' }, 45 | { text: '服务条款', link: '/terms-of-use' }, 46 | { text: '特别感谢', link: '/thanks' }, 47 | ], 48 | }, 49 | ], 50 | 51 | footer: { 52 | message: '欢迎加入UsagiPass兔兔群: 363346002', 53 | copyright: 'Copyright © 2019-2024 TuRou' 54 | }, 55 | 56 | socialLinks: [ 57 | { icon: 'github', link: 'https://github.com/TrueRou/UsagiPass' } 58 | ] 59 | } 60 | }) 61 | -------------------------------------------------------------------------------- /docs/begin.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # 开始 6 | 7 | ## 了解 DXPass 8 | 9 | DXPass 是**日服限定**的,可以在制卡机上制作的具有特殊功能的实体卡片。通常印有玩家信息、角色立绘、区域背景等元素,有提升跑图距离、解锁上位难度的谱面等等功能。 10 | 11 | ![dx-pass-collection](https://s2.loli.net/2024/10/19/13bZcj9NtnW5xDq.webp) 12 | 13 | 虽然国服无法制作 DXPass,但是独特的微信登录机制使我们有了动态生成 DXPass 的可能,UsagiPass 应运而生。 14 | 15 | ## 安装 UsagiPass 16 | 17 | 安装 UsagiPass 需要你具有一定的动手能力,下面我们将逐步讲解如何安装 UsagiPass: 18 | 19 | 1. 安装代理软件:UsagiPass 需要代理才能运行,如果您不清楚什么是代理,请点击下方推荐方式即可。 20 | - 安卓: [**Clash(推荐)**](https://dxpass.turou.fun/proxies/clash.html),[Sing-box](https://dxpass.turou.fun/proxies/singbox.html) 21 | - iOS: [**Shadowrocket(推荐)**](https://dxpass.turou.fun/proxies/rocket.html),[Sing-box](https://dxpass.turou.fun/proxies/singbox.html),[QuantX](https://dxpass.turou.fun/proxies/quantx.html) 22 | 2. 启动代理软件:UsagiPass 需要代理才能运行,请确保你的代理已经运行在手机后台。 23 | 3. 在 **微信 -> 舞萌|中二公众号** 中正常打开**登入二维码**,您应该已经跳转到UsagiPass的登录界面 24 | 4. 使用水鱼或落雪查分器账号进行登录,登录后即可进入UsagiPass主页,享受可登录的国服DXPass功能 25 | 26 | ::: tip 27 | UsagiPass 利用代理来重定向华立二维码网页,请保证在使用过程中代理不要关闭。 28 | ::: 29 | 30 | ## 使用 UsagiPass 31 | 32 | 登录UsagiPass后,将使用默认的个性化配置,点击画面右下方的齿轮图标可以进入设置页面。 33 | 34 | 在设置中,可以按照喜好调整个性化配置,切换背景、角色、边框等资源,在调整结束后可以点击最下方的保存。 35 | 36 | 请初次使用的玩家在设置中粘贴自己的好友代码 (可在舞萌DX-好友页面查询)。 37 | 38 | 同时,我们还支持覆盖页面中的部分文本,玩家可以自行尝试相关功能。 39 | 40 | ::: tip 41 | 想了解更多 UsagiPass 的功能,可以在左侧的目录 (手机用户请点击左上角Menu按钮) 找到。 42 | ::: 43 | 44 | ## 支持 UsagiPass 45 | 46 | 如果觉得 UsagiPass 好用的话,不妨给[我们的仓库](https://github.com/TrueRou/UsagiPass)点一个 ⭐ 吧。 47 | 48 | 我们也开放了爱发电入口,如果您愿意 [赞助 UsagiPass](https://afdian.com/a/turou) 我们会在特别感谢中提到您的名字。 49 | 50 | ## Q & A 51 | 52 | **Q: 我不清楚UsagiPass的某某功能如何使用** 53 | 54 | A: 可以在左侧的目录 (手机用户请点击左上角Menu按钮) 中找到部分功能的详细介绍。如果仍有疑问,可以加群进行询问。 55 | 56 | **Q: IOS 使用小火箭需要购买** 57 | 58 | A: 可以使用 Sing-box 方案,Sing-box 是一款在外区商店可以免费下载的代理软件。 59 | 60 | **Q: IOS 我不知道应该如何获得外区账号** 61 | 62 | A: 可以在B站搜索相关关键词,这类视频还是挺多的,请尽量不要在群内讨论相关话题。 63 | 64 | **Q: 在按照步骤配置后,我无法打开 UsagiPass 网页** 65 | 66 | UsagiPass 目前使用阿里云托管,域名使用阿里云个人版云解析,并且已经在工信部备案,可保证国内各地区的正常访问。 67 | 68 | 您可以随时访问我们的[状态监控](https://status.turou.fun/)页面,查看UsagiPass的运行状态。 69 | 70 | 若您在自行诊断后仍然出现无法连接的问题,请加群联系我们,我们会根据你的地区向阿里云发起工单,尝试解决你的问题。 71 | 72 | 另外,如果您的错误代码在下面表格中列出 73 | 74 | - **ERR_PROXY_CONNECTION_FAILED** 75 | - **ERR_CONNECTION_TIMED_OUT** 76 | - **ERR_HTTP_RESPONSE_CODE_FAILURE** 77 | 78 | 可能是代理连接不正确,请重启代理、重启手机、更换代理软件后重试。 -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # 参与开发 6 | 7 | ## 开源仓库 8 | 9 | UsagiPass的前端 & 后端 & 官网均已开源,欢迎提出Issues和PullRequests。 10 | 11 | 我们的 GitHub 仓库: https://github.com/TrueRou/UsagiPass 12 | 13 | ## 配置私人MITM 14 | 15 | UsagiPass 使用 [MITM中间人](https://developer.mozilla.org/zh-CN/docs/Glossary/MitM) 转发并修改来自华立服务器的流量。 16 | 17 | 当前版本的 UsagiPass 会修改来自 wq.sys-all.cn 和 tgk-wcaime.wahlap.com 的流量,后者主要用于更新查分器。 18 | 19 | 有条件的开发者可以搭建属于自己的代理服务器,可以在降低服务器负载的同时提升安全性。 20 | 21 | ### 前置环境 22 | 23 | - 合适的 Linux 发行版,这里以 Debian 为例 24 | - 能够连接至 GitHub 的网络环境 25 | - Python 3.12+ 26 | 27 | ### 搭建方式 28 | 29 | - Python 3.12 以上版本 30 | - 在 项目根目录 下创建 `.env` 文件, 可以根据 `.env.example.mitm-only` 模板文件进行配置 31 | - 执行 `pip install poetry` 全局安装 Poetry 32 | - 执行 `poetry install` 安装项目依赖 33 | - 执行 `poetry run mitm` 单独运行代理 34 | 35 | > 更新项目: `git pull && poetry install && poetry run mitm` 36 | 37 | ::: tip 38 | 切记:必须暴露 TCP `2560` 端口,如部署防火墙的请在规则允许 `2560` 端口的外网访问 39 | 40 | 端口可以在 `.env` 文件中进行配置 41 | ::: -------------------------------------------------------------------------------- /docs/implementation.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # 实现方式 6 | 7 | ## 华立的设计 8 | 9 | 当点击公众号的玩家二维码后,**公众号后台**将玩家 ID 和过期时间以一定形式编码成 SGWCMAID。 10 | 11 | 公众号返回一个 wq.sys-all.cn 的网页,查询参数中包含 SGWCMAID。 12 | 13 | 网页根据 SGWCMAID,显示对应的二维码图片和简陋的扫码界面。 14 | 15 | 舞萌 DX 识别二维码,解析出 SGWCMAID,SGWCMAID 配合机台信息和密钥,向后端请求得到玩家 ID。 16 | 17 | ::: tip 18 | 因为 SGWCMAID 在微信公众号后台生成,对于我们来说属于黑箱,且无法从玩家 ID 逆向得到 SGWCMAID. 故印制实体卡片,或者利用单片机实现算号登录的操作在当前情况下是没有可行性的。 19 | ::: 20 | 21 | ## 我们的设计 22 | 23 | 通过代理替换 sys-all.cn 网页,将请求重定向到 dxpass.turou.fun。 24 | 25 | 重定向时携带原网页中的查询参数(SGWCMAID),**在前端**以 JS 的方式直接绘制出二维码。 26 | 27 | ::: tip 28 | UsagiPass 前后端代码在 GitHub 开源:[https://github.com/TrueRou/UsagiPass](https://github.com/TrueRou/UsagiPass)。 29 | ::: 30 | 31 | ## 关于更新查分器 32 | 33 | 在20241116更新中,我们支持了更新水鱼和落雪查分器。更新原理类似 [Bakapiano方案](https://github.com/bakapiano/maimaidx-prober-proxy-updater)。 34 | 35 | 由于 UsagiPass 天然运行在微信浏览器中,我们就不需要玩家手动复制更新链接到微信并打开了。 36 | 37 | 我们同时也在代理规则中进行了处理,在合适的时候转发 tgk-wcaime.wahlap.com 地址,来获取玩家 Cookies 进而获取玩家成绩数据。 38 | 39 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: "UsagiPass" 7 | text: "动态生成可登录的 DXPASS" 8 | tagline: 从零开始美化你的玩家二维码 9 | actions: 10 | - theme: brand 11 | text: 开始使用 12 | link: /begin 13 | - theme: alt 14 | text: 手册 15 | link: /implementation 16 | - theme: alt 17 | text: ⭐ 18 | link: https://github.com/TrueRou/UsagiPass 19 | image: 20 | src: https://s2.loli.net/2024/11/17/wxty6UWMREplhsa.webp 21 | 22 | features: 23 | - title: 动态生成 24 | details: 基于水鱼/落雪查分器的数据动态生成玩家的 DXPASS,用户个性化数据云端储存 25 | - title: 高度自定义 26 | details: 自定义 DXPASS 背景、外框、角色,支持使用预设素材或个性化上传自己的图片 27 | - title: 支持登录 28 | details: 通过代理直接嵌入微信玩家二维码页面,可直接扫描 DXPASS 上机,逼格满满 29 | - title: 更新查分器 30 | details: 支持一键更新水鱼/落雪查分器,无需复杂的配置 31 | --- -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "docs:dev": "vitepress dev", 4 | "docs:build": "vitepress build", 5 | "docs:preview": "vitepress preview --port 3173" 6 | }, 7 | "devDependencies": { 8 | "vitepress": "^1.4.1" 9 | } 10 | } -------------------------------------------------------------------------------- /docs/privacy-policy.md: -------------------------------------------------------------------------------- 1 | # 隐私政策 2 | 3 | > 最后更新日期:2025/3/22 4 | 5 | --- 6 | 7 | 若要使用 [UsagiPass 和 UsagiCard](/)(以下简称本网站)的服务,请您务必仔细阅读并透彻理解本声明。 8 | 9 | 请注意,访问和使用本网站,您的使用行为将被视为对本声明全部内容的认可。如果您不同意本声明的任何内容,请您立即停止使用本网站。 10 | 11 | ## 定义 12 | 13 | 为了使本条款更加清晰,我们使用缩短的术语来表达某些概念。 14 | 15 | - **UsagiPass** 指 基于水鱼/落雪查分器的数据动态生成玩家的 DXPASS 相关服务。 16 | - **UsagiCard** 指 样式类似 DXPASS 的实体卡片以及围绕卡片提供的相关服务。 17 | - **本网站** 指 [UsagiPass 和 UsagiCard](/)。 18 | - **我们** 指 本网站的运营者 [兔肉](https://github.com/TrueRou)。 19 | - **您** 指 使用本网站的用户。 20 | - **舞萌DX** 是 由 SEGA 发行的音乐游戏。 21 | - **游戏数据** 指 您在上述游戏中产生的数据,包括但不限于您的好友码、玩家信息、玩家收藏品、游玩成绩等。 22 | 23 | ## 信息收集 24 | 25 | - 您使用本网站时,我们会收集您的 IP 地址、浏览器信息、操作系统信息、访问时间等信息。 26 | - 您使用本网站提供的 舞萌DX 游戏数据爬取服务时,我们会收集您的游戏数据。 27 | 28 | ## 信息使用 29 | 30 | - UsagiPass **明文保存**您提供的 水鱼/落雪 账号信息,用于提供鉴权和上传查分器服务。 31 | - UsagiPass **不会收集**与您二维码相关的任何信息,不会持久化保存您的任何成绩数据。 32 | - UsagiCard **加密保存**您提供的舞萌DX机台用户信息,用于提供实体卡片的相关服务。 33 | - 本网站收集的信息仅用于提供本网站的服务,不会用于任何其他用途。 34 | 35 | ## 条款修改 36 | 37 | - 本条款是您在本网站签署的使用协议的组成部分之一,请您仔细阅读。 38 | - 当条款发生变更时,我们会在本页面上发布通知。如果您在条款修改后继续使用本网站提供的服务,即表示您同意并接受修改后的条款。 39 | - 我们保留对本条款作出不定时修改的权利。 40 | - 我们对本页面内容拥有最终解释权。 41 | -------------------------------------------------------------------------------- /docs/proxies/clash.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Clash / ClashMeta 配置 6 | 7 | 1. 安装Clash / ClashMeta: 8 | - **Github(推荐)**:[https://github.com/MetaCubeX/ClashMetaForAndroid/releases](https://github.com/MetaCubeX/ClashMetaForAndroid/releases) 9 | - **蓝奏云(国内)**:[https://wwps.lanzouj.com/iXMCk2cydjmd](https://wwps.lanzouj.com/iXMCk2cydjmd) 10 | - 加入UsagiPass用户群,在群公告处获取: 363346002 11 | 2. 导入UsagiPass配置 12 | - 一键导入: [导入Clash配置](clash://install-config?url=https://dxpass.turou.fun/UsagiPass.yaml&name=UsagiPass) 13 | - 复制链接到Clash导入:https://dxpass.turou.fun/UsagiPass.yaml 14 | - 加入 UsagiPass 用户群,在群文件获取配置:363346002 15 | 3. **确保选择了UsagiPass配置,点击启动代理** 16 | 17 | ::: tip 18 | Clash 和 ClashMeta 都支持 UsagiPass,已经安装过系列软件的不需要重新安装 19 | ::: -------------------------------------------------------------------------------- /docs/proxies/quantx.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Quantumult X 配置 6 | 7 | ::: tip 8 | Quantumult X 需要外区账号,且购买后才能安装. 如果你对如何获取圈X感到疑惑,推荐 iOS 用户使用 Sing-box 方案 9 | ::: 10 | 11 | 1. 安装 Quantumult X: 12 | - 请自行寻找安装方法 13 | - 请尽量不要在 UsagiPass 用户群寻求安装帮助,如果安装有困难,推荐查看 Sing-box 方案 14 | 2. 导入 UsagiPass 配置 15 | - 复制链接到小火箭导入: https://dxpass.turou.fun/UsagiPass.conf 16 | - 加入 UsagiPass 用户群,在群文件获取配置: 363346002 17 | 3. **确保选择了 UsagiPass 配置,确保“全局代理”处开启了配置模式,点击启动代理** -------------------------------------------------------------------------------- /docs/proxies/rocket.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Shadowrocket 配置 6 | 7 | ::: tip 8 | Shadowrocket 需要美区账号,且购买后才能安装. 如果你对如何获取小火箭感到疑惑,推荐 iOS 用户使用 Sing-box 方案 9 | ::: 10 | 11 | 1. 安装 Shadowrocket(小火箭): 12 | - 请自行寻找安装方法 13 | - 请尽量不要在 UsagiPass 用户群寻求安装帮助,如果安装有困难,推荐查看 Sing-box 方案 14 | 2. 导入 UsagiPass 配置 15 | - 一键导入: [导入配置](clash://install-config?url=https://dxpass.turou.fun/UsagiPass.yaml&name=UsagiPass) 16 | - 复制链接到小火箭导入: https://dxpass.turou.fun/UsagiPass.yaml 17 | - 加入 UsagiPass 用户群,在群文件获取配置 (使用 Clash 配置就可以了): 363346002 18 | 3. **确保选择了 UsagiPass 配置,确保“全局代理”处开启了配置模式,点击启动代理** -------------------------------------------------------------------------------- /docs/proxies/singbox.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Sing-box 配置 6 | 7 | ::: tip 8 | 感谢 MisakaNo 提供 Sing-box 配置文件 9 | ::: 10 | 11 | 1. iOS 使用 Sing-box: 12 | 1. 切换外区账号,在 AppStore 中搜索 Sing-box VT 并下载 13 | 2. Profiles -> New Profile -> Name: 随意,Type: Remote,URL: https://dxpass.turou.fun/UsagiPass.json -> Create 14 | 2. 安卓使用 Sing-box: 15 | 1. 下载 Sing-box 16 | - **Github(推荐)**:[https://github.com/SagerNet/sing-box/releases](https://github.com/SagerNet/sing-box/releases) 17 | - Google Play:搜索 Sing-box 并下载 18 | - 加入 UsagiPass 用户群,在群公告处获取: 363346002 19 | 2. Profiles -> 新建图标 -> **Create Manually** -> Name: 随意,Type: Remote,URL: https://dxpass.turou.fun/UsagiPass.json -> Create 20 | 3. **确保选择了 UsagiPass 配置,点击启动代理** 21 | 22 | ::: tip 23 | 也可以选择使用文件导入,Type 处选择 Local 即可 24 | 25 | 安卓用户如果在导入文件过程中出现 invalid message 错误,请仔细阅读导入流程(一定要选择 Create Manually,而不是 Import from file) 26 | ::: -------------------------------------------------------------------------------- /docs/public/UsagiPass.conf: -------------------------------------------------------------------------------- 1 | [general] 2 | excluded_routes=192.168.0.0/16, 172.16.0.0/12, 100.64.0.0/10, 10.0.0.0/8 3 | geo_location_checker=http://ip-api.com/json/?lang=zh-CN 4 | network_check_url=http://www.baidu.com/ 5 | server_check_url=http://www.baidu.com/ 6 | 7 | [dns] 8 | server=119.29.29.29 9 | server=223.5.5.5 10 | server=114.114.114.114 11 | 12 | [policy] 13 | static=UsagiPass 14 | 15 | [server_remote] 16 | 17 | [filter_remote] 18 | 19 | [rewrite_remote] 20 | 21 | [server_local] 22 | shadowsocks = up.turou.fun:16789, method=chacha20-ietf-poly1305, password=o5bZUHHvbFLy3eXkzi2M, fast-open=false, udp-relay=false, tag=UsagiPass 23 | 24 | [filter_local] 25 | host-suffix,sys-all.cn,UsagiPass 26 | host-suffix,sys-allnet.cn,UsagiPass 27 | host-suffix,tgk-wcaime.wahlap.com,UsagiPass 28 | FINAL,DIRECT 29 | 30 | [rewrite_local] 31 | 32 | [mitm] 33 | 34 | -------------------------------------------------------------------------------- /docs/public/UsagiPass.json: -------------------------------------------------------------------------------- 1 | { 2 | "log": { 3 | "level": "info", 4 | "timestamp": true 5 | }, 6 | "dns": { 7 | "servers": [ 8 | { 9 | "tag": "dns_resolver", 10 | "address": "local", 11 | "detour": "direct" 12 | } 13 | ], 14 | "rules": [ 15 | { 16 | "outbound": "any", 17 | "server": "dns_resolver" 18 | } 19 | ], 20 | "final": "dns_resolver", 21 | "strategy": "prefer_ipv4" 22 | }, 23 | "inbounds": [ 24 | { 25 | "type": "tun", 26 | "tag": "tun-in", 27 | "mtu": 1400, 28 | "auto_route": true, 29 | "strict_route": true, 30 | "stack": "mixed", 31 | "sniff": true, 32 | "address": [ 33 | "172.18.0.1/30", 34 | "fdfe:dcba:9876::1/126" 35 | ] 36 | } 37 | ], 38 | "outbounds": [ 39 | { 40 | "type": "shadowsocks", 41 | "tag": "UsagiPass", 42 | "server": "up.turou.fun", 43 | "server_port": 16789, 44 | "method": "chacha20-ietf-poly1305", 45 | "password": "o5bZUHHvbFLy3eXkzi2M", 46 | "network": "tcp", 47 | "tcp_fast_open": false 48 | }, 49 | { 50 | "type": "direct", 51 | "tag": "direct" 52 | }, 53 | { 54 | "type": "block", 55 | "tag": "block" 56 | }, 57 | { 58 | "type": "dns", 59 | "tag": "dns-out" 60 | } 61 | ], 62 | "route": { 63 | "rules": [ 64 | { 65 | "protocol": "dns", 66 | "outbound": "dns-out" 67 | }, 68 | { 69 | "domain": [ 70 | "wq.sys-all.cn", 71 | "wq.sys-allnet.cn", 72 | "tgk-wcaime.wahlap.com" 73 | ], 74 | "ip_cidr": [ 75 | "152.136.21.46/32", 76 | "42.193.74.107/32", 77 | "129.28.248.89/32", 78 | "43.137.91.207/32", 79 | "81.71.193.236/32", 80 | "43.145.45.124/32" 81 | ], 82 | "outbound": "UsagiPass" 83 | }, 84 | { 85 | "ip_is_private": true, 86 | "outbound": "direct" 87 | } 88 | ], 89 | "final": "direct", 90 | "auto_detect_interface": true, 91 | "override_android_vpn": true 92 | }, 93 | "experimental": { 94 | "cache_file": { 95 | "enabled": true, 96 | "path": "cache.db" 97 | } 98 | } 99 | } -------------------------------------------------------------------------------- /docs/public/UsagiPass.yaml: -------------------------------------------------------------------------------- 1 | mixed-port: 7890 2 | allow-lan: false 3 | mode: rule 4 | log-level: info 5 | 6 | proxies: 7 | - { 8 | "name": "UsagiPass", 9 | "type": "ss", 10 | "server": "up.turou.fun", 11 | "port": 16789, 12 | "cipher": "chacha20-ietf-poly1305", 13 | "password": "o5bZUHHvbFLy3eXkzi2M", 14 | "udp": false 15 | } 16 | 17 | rules: 18 | - DOMAIN-SUFFIX,sys-all.cn,UsagiPass 19 | - DOMAIN-SUFFIX,sys-allnet.cn,UsagiPass 20 | - DOMAIN,tgk-wcaime.wahlap.com,UsagiPass 21 | - MATCH,DIRECT -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrueRou/UsagiPass/e54154e76c6b1fb9b945718e875a2c63708f9814/docs/public/favicon.ico -------------------------------------------------------------------------------- /docs/terms-of-use.md: -------------------------------------------------------------------------------- 1 | # 服务条款 2 | 3 | > 最后更新日期:2025/3/22 4 | 5 | --- 6 | 7 | 若要使用 [UsagiPass 和 UsagiCard](/)(以下简称本网站)的服务,请您务必仔细阅读并透彻理解本声明。 8 | 9 | 请注意,访问和使用本网站,您的使用行为将被视为对本声明全部内容的认可。如果您不同意本声明的任何内容,请您立即停止使用本网站。 10 | 11 | ## 定义 12 | 13 | 为了使本条款更加清晰,我们使用缩短的术语来表达某些概念。 14 | 15 | - **UsagiPass** 指 基于水鱼/落雪查分器的数据动态生成玩家的 DXPASS 相关服务。 16 | - **UsagiCard** 指 样式类似 DXPASS 的实体卡片以及围绕卡片提供的相关服务。 17 | - **本网站** 指 [UsagiPass 和 UsagiCard](/)。 18 | - **我们** 指 本网站的运营者 [兔肉](https://github.com/TrueRou)。 19 | - **您** 指 使用本网站的用户。 20 | - **舞萌DX** 是 由 SEGA 发行的音乐游戏。 21 | - **游戏数据** 指 您在上述游戏中产生的数据,包括但不限于您的好友码、玩家信息、玩家收藏品、游玩成绩等。 22 | 23 | ## 服务内容 24 | 25 | - UsagiPass 提供的服务内容包括但不限于: 26 | - 代理 “舞萌 | 中二” 公众号登入二维码网页,为您提供更美观的页面服务。 27 | - 代理 “舞萌 | 中二” 公众号我的记录网页,为您提供 舞萌DX 游戏数据爬取等服务。 28 | - 使用您的 水鱼/落雪 账号进行鉴权,为您提供上传查分器等服务。 29 | - UsagiCard 提供的服务内容包括但不限于: 30 | - 印制样式类似 DXPASS 的实体卡片,为您提供实体卡片的相关服务。 31 | - 通过您提供的二维码,获取您的机台用户信息,为您提供 舞萌DX 游戏数据爬取、查询等服务。 32 | - 本网站提供的服务仅供个人学习、研究或欣赏使用,您不得将本网站提供的服务用于商业用途。 33 | - 本网站提供的服务内容可能会随时变更,本网站不承诺对用户提供任何形式的通知。 34 | 35 | ### 特别声明 36 | 37 | - UsagiPass 与 UsagiCard 为本网站提供的两个相对独立的服务,与 SEGA 公司无关。 38 | - UsagiPass 与 UsagiCard 之间的数据不会相互共享,您在 UsagiPass 的数据不会被 UsagiCard 访问,反之亦然。 39 | - 原则上 UsagiCard 仅作为礼品赠送,属于非卖品,我们不提供任何形式的售卖服务,拒绝任何形式的商业用途。 40 | - 您有权在任何时候删除您在本网站的数据,我们会在收到您的删除请求后立即永久删除您的相关数据。 41 | 42 | ## 用户行为 43 | 44 | - 您在使用本网站提供的服务时,应遵守中华人民共和国相关法律法规,不得利用本网站提供的服务从事任何违法违规行为。 45 | - 您不得利用本网站提供的服务干扰本网站的正常运行,包括但不限于利用本网站提供的服务对本网站的服务器进行攻击、利用本网站提供的服务对本网站的数据进行篡改等。 46 | - 对于任何违反上述协议的行为,本网站有权采取措施,包括但不限于限制、禁止您使用本网站提供的服务。 47 | 48 | ## 免责声明 49 | 50 | 您已知晓并同意,您使用本网站提供的服务存在违反[《舞萌&中二 游戏条款》](http://wc.wahlap.net/sega/music/terms/index.html)的风险,本网站不对您使用本网站提供的服务所产生的后果承担任何责任。 51 | 52 | ## 条款修改 53 | 54 | - 本条款是您在本网站签署的使用协议的组成部分之一,请您仔细阅读。 55 | - 当条款发生变更时,我们会在本页面上发布通知。如果您在条款修改后继续使用本网站提供的服务,即表示您同意并接受修改后的条款。 56 | - 我们保留对本条款作出不定时修改的权利。 57 | - 我们对本页面内容拥有最终解释权。 58 | -------------------------------------------------------------------------------- /docs/thanks.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # 特别感谢 6 | 7 | 特别感谢朋友们对 UsagiPass 的支持和贡献,以下排名不分先后: 8 | 9 | - MisakaNo 10 | - Sanzisbar 11 | - 阿日 12 | - ✯以太✬ 13 | - fxysk 14 | - 柠檬诺lemon 15 | - 原味零花 16 | - 生盐诺亚 17 | - Dream_Rain 18 | 19 | 另外,UsagiPass 建立在许多开源项目和开发者的技术积累上,特别感谢以下开发者和相关项目 20 | 21 | - Diving-Fish: https://www.diving-fish.com/maimaidx/prober/ 22 | - LXNS: https://maimai.lxns.net/ 23 | - Bakapiano: https://github.com/bakapiano/maimaidx-prober-proxy-updater 24 | - maimai.py: https://github.com/TrueRou/maimai.py -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | in-project = true 3 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "UsagiPass" 3 | version = "0.1.3" 4 | description = "" 5 | authors = [ 6 | {name = "Usagi no Niku",email = "chenbohan911@163.com"} 7 | ] 8 | license = "GPL-3.0" 9 | packages = [ 10 | { include = "usagipass" } 11 | ] 12 | readme = "README.md" 13 | requires-python = ">=3.12,<4.0" 14 | dependencies = [ 15 | "fastapi (>=0.115.8,<0.116.0)", 16 | "httpx (>=0.28.1,<0.29.0)", 17 | "maimai-py (>=1.0.4,<2.0.0)", 18 | "mitmproxy (>=11.1.3,<12.0.0)", 19 | "pillow (>=11.1.0,<12.0.0)", 20 | "sqlmodel (>=0.0.22,<0.0.23)", 21 | "sqlalchemy (>=2.0.38,<3.0.0)", 22 | "uvicorn (>=0.34.0,<0.35.0)", 23 | "dotenv (>=0.9.9,<0.10.0)", 24 | "alembic (>=1.14.1,<2.0.0)", 25 | "pyjwt (>=2.10.1,<3.0.0)", 26 | "tenacity (>=9.0.0,<10.0.0)", 27 | "python-multipart (>=0.0.20,<0.0.21)", 28 | "pymysql (>=1.1.1,<2.0.0)", 29 | "tzdata (>=2025.1,<2026.0)", 30 | "redis (>=6.0.0,<7.0.0)", 31 | "aiomysql (>=0.2.0,<0.3.0)", 32 | ] 33 | 34 | [tool.poetry.scripts] 35 | app = "usagipass.main:main" 36 | mitm = "usagipass.main:mitm_main" 37 | import = "usagipass.tools.importer:main" 38 | 39 | [[tool.poetry.source]] 40 | name = "mirrors" 41 | url = "https://pypi.tuna.tsinghua.edu.cn/simple/" 42 | priority = "primary" 43 | 44 | [build-system] 45 | requires = ["poetry-core>=2.0.0,<3.0.0"] 46 | build-backend = "poetry.core.masonry.api" 47 | -------------------------------------------------------------------------------- /usagipass/.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | -------------------------------------------------------------------------------- /usagipass/app/api/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from usagipass.app.api import users, images, servers, accounts, announcements, v1 3 | 4 | router = APIRouter() 5 | router.include_router(v1.router) 6 | router.include_router(users.router) 7 | router.include_router(accounts.router) 8 | router.include_router(images.router) 9 | router.include_router(servers.router) 10 | router.include_router(announcements.router) 11 | -------------------------------------------------------------------------------- /usagipass/app/api/accounts.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Annotated 3 | 4 | from fastapi import APIRouter, Depends, HTTPException 5 | from fastapi.security import OAuth2PasswordRequestForm 6 | from httpx import ConnectError, ReadTimeout 7 | from sqlmodel.ext.asyncio.session import AsyncSession 8 | 9 | from usagipass.app.database import httpx_client, require_session 10 | from usagipass.app.models import AccountServer, User 11 | from usagipass.app.usecases import accounts, authorize, crawler, maimai 12 | from usagipass.app.usecases.authorize import verify_user 13 | from usagipass.app.usecases.crawler import CrawlerResult 14 | 15 | router = APIRouter(prefix="/accounts", tags=["accounts"]) 16 | 17 | 18 | @router.post("/token/divingfish") 19 | async def get_token_divingfish(form_data: Annotated[OAuth2PasswordRequestForm, Depends()], session: AsyncSession = Depends(require_session)): 20 | account_name = form_data.username 21 | if await accounts.auth_divingfish(account_name, form_data.password): 22 | user = await accounts.merge_user(session, account_name, AccountServer.DIVING_FISH) 23 | await accounts.merge_divingfish(session, user, account_name, form_data.password) 24 | await session.commit() 25 | return authorize.grant_user(user) 26 | 27 | 28 | @router.post("/token/lxns") 29 | async def get_token_lxns(form_data: Annotated[OAuth2PasswordRequestForm, Depends()], session: AsyncSession = Depends(require_session)): 30 | personal_token = form_data.password 31 | if profile := await accounts.auth_lxns(personal_token): 32 | account_name = str(profile["friend_code"]) 33 | user = await accounts.merge_user(session, account_name, AccountServer.LXNS) 34 | await accounts.merge_lxns(session, user, personal_token) 35 | await session.commit() 36 | return authorize.grant_user(user) 37 | 38 | 39 | @router.post("/bind/divingfish") 40 | async def bind_diving( 41 | form_data: Annotated[OAuth2PasswordRequestForm, Depends()], session: AsyncSession = Depends(require_session), user: User = Depends(verify_user) 42 | ): 43 | await accounts.merge_divingfish(session, user, form_data.username, form_data.password) 44 | asyncio.create_task(maimai.update_rating_passive(user.username)) 45 | await session.commit() 46 | 47 | 48 | @router.post("/bind/lxns") 49 | async def bind_lxns( 50 | form_data: Annotated[OAuth2PasswordRequestForm, Depends()], session: AsyncSession = Depends(require_session), user: User = Depends(verify_user) 51 | ): 52 | personal_token = form_data.password 53 | await accounts.merge_lxns(session, user, personal_token) 54 | asyncio.create_task(maimai.update_rating_passive(user.username)) 55 | await session.commit() 56 | 57 | 58 | @router.post("/update/oauth") 59 | async def update_prober_oauth(): 60 | try: 61 | resp = await httpx_client.get("https://tgk-wcaime.wahlap.com/wc_auth/oauth/authorize/maimai-dx") 62 | except (ConnectError, ReadTimeout): 63 | raise HTTPException(status_code=503, detail="无法连接到华立 OAuth 服务", headers={"WWW-Authenticate": "Bearer"}) 64 | if not resp.headers.get("location"): 65 | raise HTTPException(status_code=500, detail="华立 OAuth 服务返回的响应不正确", headers={"WWW-Authenticate": "Bearer"}) 66 | # example: https://open.weixin.qq.com/connect/oauth2/authorize?appid=wx1fcecfcbd16803b1&redirect_uri=https%3A%2F%2Ftgk-wcaime.wahlap.com%2Fwc_auth%2Foauth%2Fcallback%2Fmaimai-dx%3Fr%3DINesnJ5e%26t%3D214115533&response_type=code&scope=snsapi_base&state=5E7AB78BF1B35471B7BF8DD69E6B50F4361818FA6E01FC#wechat_redirect 67 | return {"url": resp.headers["location"].replace("redirect_uri=https", "redirect_uri=http")} 68 | 69 | 70 | @router.get("/update/callback", response_model=list[CrawlerResult]) 71 | async def update_prober_callback( 72 | r: str, 73 | t: str, 74 | code: str, 75 | state: str, 76 | user: User = Depends(verify_user), 77 | session: AsyncSession = Depends(require_session), 78 | ): 79 | params = {"r": r, "t": t, "code": code, "state": state} 80 | headers = { 81 | "User-Agent": "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36 NetType/WIFI MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x6307001e)", 82 | "Host": "tgk-wcaime.wahlap.com", 83 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", 84 | } 85 | try: 86 | resp = await httpx_client.get("https://tgk-wcaime.wahlap.com/wc_auth/oauth/callback/maimai-dx", params=params, headers=headers) 87 | if resp.status_code == 302 and resp.next_request: 88 | resp = await httpx_client.get(resp.next_request.url, headers=headers) 89 | results = await crawler.crawl_async(resp.cookies, user, session) 90 | return results 91 | raise HTTPException(status_code=400, detail="华立 OAuth 已过期或无效", headers={"WWW-Authenticate": "Bearer"}) 92 | except (ConnectError, ReadTimeout): 93 | raise HTTPException(status_code=503, detail="无法连接到华立服务器", headers={"WWW-Authenticate": "Bearer"}) 94 | -------------------------------------------------------------------------------- /usagipass/app/api/announcements.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from fastapi import APIRouter, Depends, HTTPException, status 4 | from sqlmodel import select 5 | from sqlmodel.ext.asyncio.session import AsyncSession 6 | 7 | from usagipass.app.database import add_model, partial_update_model, require_session 8 | from usagipass.app.models import Announcement, AnnouncementCreate, AnnouncementPublic, AnnouncementRead, AnnouncementUpdate, Privilege, User 9 | from usagipass.app.usecases.authorize import verify_user 10 | 11 | router = APIRouter(prefix="/announcements", tags=["announcements"]) 12 | 13 | 14 | @router.post("", response_model=AnnouncementPublic, status_code=status.HTTP_201_CREATED) 15 | async def create_announcement( 16 | announcement: AnnouncementCreate, 17 | user: User = Depends(verify_user), 18 | session: AsyncSession = Depends(require_session), 19 | ): 20 | if user.privilege != Privilege.ADMIN: 21 | raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有管理员可以创建公告") 22 | 23 | new_announcement = Announcement(**announcement.model_dump()) 24 | await add_model(session, new_announcement) 25 | await session.commit() 26 | return new_announcement 27 | 28 | 29 | @router.get("", response_model=list[AnnouncementPublic]) 30 | async def list_announcements( 31 | user: User = Depends(verify_user), 32 | session: AsyncSession = Depends(require_session), 33 | ): 34 | announcements = await session.exec(select(Announcement)) 35 | 36 | result = [] 37 | for announcement in announcements: 38 | clause = select(AnnouncementRead).where(AnnouncementRead.announcement_id == announcement.id, AnnouncementRead.username == user.username) 39 | read_record = (await session.exec(clause)).first() 40 | 41 | announcement_public = AnnouncementPublic(**announcement.model_dump(), is_read=bool(read_record)) 42 | result.append(announcement_public) 43 | 44 | return result 45 | 46 | 47 | @router.get("/{announcement_id}", response_model=AnnouncementPublic) 48 | async def get_announcement( 49 | announcement_id: int, 50 | user: User = Depends(verify_user), 51 | session: AsyncSession = Depends(require_session), 52 | ): 53 | announcement = await session.get(Announcement, announcement_id) 54 | if not announcement: 55 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="公告不存在") 56 | 57 | clause = select(AnnouncementRead).where(AnnouncementRead.announcement_id == announcement_id, AnnouncementRead.username == user.username) 58 | read_record = (await session.exec(clause)).first() 59 | 60 | return AnnouncementPublic(**announcement.model_dump(), is_read=bool(read_record)) 61 | 62 | 63 | @router.patch("/{announcement_id}", response_model=AnnouncementPublic) 64 | async def update_announcement( 65 | announcement_id: int, 66 | announcement_update: AnnouncementUpdate, 67 | user: User = Depends(verify_user), 68 | session: AsyncSession = Depends(require_session), 69 | ): 70 | if user.privilege != Privilege.ADMIN: 71 | raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有管理员可以更新公告") 72 | announcement = await session.get(Announcement, announcement_id) 73 | if not announcement: 74 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="公告不存在") 75 | 76 | announcement.updated_at = datetime.utcnow() 77 | await partial_update_model(session, announcement, announcement_update) 78 | await session.commit() 79 | 80 | return AnnouncementPublic(**announcement.model_dump(), is_read=False) # 修改后重置为未读状态 81 | 82 | 83 | @router.delete("/{announcement_id}", status_code=status.HTTP_204_NO_CONTENT) 84 | async def delete_announcement( 85 | announcement_id: int, 86 | user: User = Depends(verify_user), 87 | session: AsyncSession = Depends(require_session), 88 | ): 89 | if user.privilege != Privilege.ADMIN: 90 | raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有管理员可以删除公告") 91 | 92 | announcement = await session.get(Announcement, announcement_id) 93 | if not announcement: 94 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="公告不存在") 95 | 96 | await session.delete(announcement) 97 | await session.commit() 98 | 99 | 100 | @router.post("/{announcement_id}/read", status_code=status.HTTP_204_NO_CONTENT) 101 | async def mark_announcement_as_read( 102 | announcement_id: int, 103 | user: User = Depends(verify_user), 104 | session: AsyncSession = Depends(require_session), 105 | ): 106 | announcement = session.get(Announcement, announcement_id) 107 | if not announcement: 108 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="公告不存在") 109 | 110 | clause = select(AnnouncementRead).where(AnnouncementRead.announcement_id == announcement_id, AnnouncementRead.username == user.username) 111 | read_record = (await session.exec(clause)).first() 112 | 113 | if not read_record: 114 | read_record = AnnouncementRead( 115 | announcement_id=announcement_id, 116 | username=user.username, 117 | ) 118 | await add_model(session, read_record) 119 | await session.commit() 120 | -------------------------------------------------------------------------------- /usagipass/app/api/images.py: -------------------------------------------------------------------------------- 1 | import io 2 | import uuid 3 | from pathlib import Path 4 | 5 | import PIL.Image 6 | from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status 7 | from fastapi.responses import FileResponse 8 | from PIL.Image import Image as PILImage 9 | from PIL.Image import Resampling 10 | from sqlmodel import col, select 11 | from sqlmodel.ext.asyncio.session import AsyncSession 12 | 13 | from usagipass.app.database import require_session, session_ctx 14 | from usagipass.app.models import Image, ImagePublic, User, image_kinds, sega_prefixs 15 | from usagipass.app.usecases.authorize import verify_user 16 | 17 | router = APIRouter(prefix="/images", tags=["images"]) 18 | data_folder = Path.cwd() / ".data" 19 | images_folder = Path.cwd() / ".data" / "images" 20 | thumbnail_folder = Path.cwd() / ".data" / "thumbnails" 21 | data_folder.mkdir(exist_ok=True) 22 | images_folder.mkdir(exist_ok=True) 23 | thumbnail_folder.mkdir(exist_ok=True) 24 | 25 | 26 | async def require_image(image_id: uuid.UUID, session: AsyncSession = Depends(require_session)) -> Image: 27 | image = await session.get(Image, str(image_id)) 28 | image_path = images_folder / f"{image_id}.webp" 29 | if image is None: 30 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片未找到") 31 | if not image_path.exists(): 32 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片文件未找到") 33 | return image 34 | 35 | 36 | def _remove_sega_prefix(name: str) -> str: 37 | for prefix in sega_prefixs: 38 | if name.startswith(prefix): 39 | return name[len(prefix) :] 40 | return name 41 | 42 | 43 | @router.post("", response_model=ImagePublic, status_code=status.HTTP_201_CREATED) 44 | def upload_image( 45 | name: str, 46 | kind: str, 47 | user: User = Depends(verify_user), 48 | file: UploadFile = File(...), 49 | ): 50 | if kind not in image_kinds.keys(): 51 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图片种类无效") 52 | image_bytes = file.file.read() 53 | try: 54 | with session_ctx() as session: 55 | file_name = str(uuid.uuid4()) 56 | image: PILImage = PIL.Image.open(io.BytesIO(image_bytes)).convert("RGBA") 57 | image = image.resize(image_kinds[kind]["hw"][0], resample=Resampling.BILINEAR) 58 | image.save(images_folder / f"{file_name}.webp", "webp", optimize=True, quality=80) 59 | db_image = Image(id=file_name, name=name, kind=kind, uploaded_by=user.username) 60 | session.add(db_image) 61 | session.commit() 62 | session.refresh(db_image) 63 | return db_image 64 | except PIL.UnidentifiedImageError: 65 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="无法加载图片文件") 66 | except ValueError: 67 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图片宽度或高度不规范") 68 | 69 | 70 | @router.get("/{image_id}") 71 | async def get_image(image: Image = Depends(require_image)): 72 | image_path = images_folder / f"{image.id}.webp" 73 | return FileResponse(image_path, media_type="image/webp") 74 | 75 | 76 | @router.delete("/{image_id}") 77 | async def delete_image( 78 | user: User = Depends(verify_user), 79 | session: AsyncSession = Depends(require_session), 80 | image: Image = Depends(require_image), 81 | ): 82 | if image.uploaded_by != user.username: 83 | raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="您不是此图片的所有者") 84 | await session.delete(image) 85 | image_path = images_folder / f"{image.id}.png" 86 | image_path.unlink(missing_ok=True) 87 | await session.commit() 88 | return {"message": "图片已删除"} 89 | 90 | 91 | @router.patch("/{image_id}") 92 | async def patch_image( 93 | name: str, 94 | user: User = Depends(verify_user), 95 | session: AsyncSession = Depends(require_session), 96 | image: Image = Depends(require_image), 97 | ): 98 | if image.uploaded_by != user.username: 99 | raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="您不是此图片的所有者") 100 | image.name = name 101 | await session.commit() 102 | return {"message": "图片已重命名"} 103 | 104 | 105 | @router.get("/{image_id}/related", response_model=list[ImagePublic]) 106 | async def get_images_related(session: AsyncSession = Depends(require_session), image: Image = Depends(require_image)): 107 | if image.sega_name: 108 | suffix = _remove_sega_prefix(image.sega_name) 109 | return await session.exec(select(Image).where(col(Image.sega_name).endswith(suffix))) 110 | return [] 111 | 112 | 113 | @router.get("/{image_id}/thumbnail") 114 | def get_image_thumbnail(image_id: uuid.UUID): 115 | # we don't verify the image here, due to performance reasons 116 | thumbnail_path = thumbnail_folder / f"{image_id}.webp" 117 | image_path = images_folder / f"{image_id}.webp" 118 | if not thumbnail_path.exists(): 119 | if not image_path.exists(): 120 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片文件未找到") 121 | thumbnail = PIL.Image.open(image_path) 122 | thumbnail.thumbnail((256, 256)) 123 | thumbnail.save(thumbnail_path, "webp", optimize=True, quality=80) 124 | return FileResponse(thumbnail_path, media_type="image/webp") 125 | -------------------------------------------------------------------------------- /usagipass/app/api/servers.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends 2 | from sqlmodel import col, or_, select 3 | from sqlmodel.ext.asyncio.session import AsyncSession 4 | 5 | from usagipass.app.database import require_session 6 | from usagipass.app.models import Image, ImagePublic, User, image_kinds 7 | from usagipass.app.usecases import authorize 8 | 9 | router = APIRouter(tags=["servers"]) 10 | 11 | 12 | @router.get("/kinds") 13 | async def get_kinds(): 14 | return image_kinds 15 | 16 | 17 | @router.get("/bits", response_model=list[ImagePublic]) 18 | async def get_images(user: User | None = Depends(authorize.verify_user_optional), session: AsyncSession = Depends(require_session)): 19 | clause = ( 20 | select(Image).where(Image.uploaded_by == None).order_by(col(Image.uploaded_at).desc()) 21 | if user is None 22 | else select(Image).where(or_(Image.uploaded_by == None, Image.uploaded_by == user.username)).order_by(col(Image.uploaded_at).desc()) 23 | ) 24 | return await session.exec(clause) 25 | -------------------------------------------------------------------------------- /usagipass/app/api/users.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import uuid 3 | from datetime import datetime 4 | 5 | from fastapi import APIRouter, Depends, HTTPException, status 6 | from sqlmodel import select 7 | from sqlmodel.ext.asyncio.session import AsyncSession 8 | 9 | from usagipass.app import database 10 | from usagipass.app.database import async_session_ctx, partial_update_model, require_session 11 | from usagipass.app.models import * 12 | from usagipass.app.usecases import maimai 13 | from usagipass.app.usecases.accounts import apply_preference 14 | from usagipass.app.usecases.authorize import verify_user 15 | 16 | router = APIRouter(prefix="/users", tags=["users"]) 17 | 18 | 19 | @router.patch("") 20 | async def update_user(user_update: UserUpdate, user: User = Depends(verify_user), session: AsyncSession = Depends(require_session)): 21 | user.updated_at = datetime.utcnow() 22 | await partial_update_model(session, user, user_update) 23 | await session.commit() 24 | return {"message": "User has been updated"} 25 | 26 | 27 | @router.get("/profile", response_model=UserProfile) 28 | async def get_profile(user: User = Depends(verify_user), session: AsyncSession = Depends(require_session)): 29 | async def prepare_preference() -> PreferencePublic: 30 | async with async_session_ctx() as scoped_session: 31 | db_preference = await scoped_session.get(UserPreference, user.username) 32 | if not db_preference: 33 | db_preference = UserPreference(username=user.username) 34 | await database.add_model(scoped_session, db_preference) 35 | preferences = PreferencePublic.model_validate(db_preference) 36 | await apply_preference(preferences, db_preference, scoped_session) # apply the default images if the user has not set up 37 | await scoped_session.commit() 38 | return preferences 39 | 40 | # we don't wait for this task to finish, because it will take a long time 41 | asyncio.create_task(maimai.update_rating_passive(user.username)) 42 | task_accounts = asyncio.create_task(session.exec(select(UserAccount).where(UserAccount.username == user.username))) 43 | task_preference = asyncio.create_task(prepare_preference()) 44 | 45 | db_accounts, preferences = await asyncio.gather(task_accounts, task_preference) 46 | accounts = {account.account_server: UserAccountPublic.model_validate(account) for account in db_accounts} 47 | api_token = user.api_token or "" 48 | async with async_session_ctx() as scoped_session: 49 | scoped_user = await scoped_session.get(User, user.username) 50 | if scoped_user and not scoped_user.api_token: 51 | api_token = uuid.uuid4().hex 52 | scoped_user.api_token = api_token 53 | await scoped_session.commit() 54 | 55 | user_profile = UserProfile( 56 | username=user.username, 57 | api_token=api_token, 58 | prefer_server=user.prefer_server, 59 | privilege=user.privilege, 60 | preferences=preferences, 61 | accounts=accounts, 62 | ) 63 | return user_profile 64 | 65 | 66 | @router.patch("/preference") 67 | async def update_preference( 68 | preference: PreferencePublic, 69 | user: User = Depends(verify_user), 70 | session: AsyncSession = Depends(require_session), 71 | ): 72 | db_preference = await session.get(UserPreference, user.username) 73 | if not db_preference: 74 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户尚未设置其偏好") 75 | if db_preference.username != user.username: 76 | raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="您不是该偏好设置的所有者") 77 | update_preference = PreferenceUpdate( 78 | **preference.model_dump(exclude={"character", "background", "frame", "passname"}), 79 | character_id=preference.character.id if preference.character else None, 80 | background_id=preference.background.id if preference.background else None, 81 | frame_id=preference.frame.id if preference.frame else None, 82 | passname_id=preference.passname.id if preference.passname else None, 83 | ) 84 | user.updated_at = datetime.utcnow() 85 | # there's no problem with the image ids, we can update the preference 86 | await partial_update_model(session, db_preference, update_preference) 87 | await session.commit() 88 | return {"message": "Preference has been updated"} 89 | -------------------------------------------------------------------------------- /usagipass/app/api/v1/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from usagipass.app.api.v1 import accounts 4 | 5 | 6 | router = APIRouter(prefix="/v1", tags=["v1"]) 7 | router.include_router(accounts.router) 8 | -------------------------------------------------------------------------------- /usagipass/app/api/v1/accounts.py: -------------------------------------------------------------------------------- 1 | import string 2 | 3 | from fastapi import APIRouter, Depends, HTTPException 4 | from sqlmodel import SQLModel, select 5 | from sqlmodel.ext.asyncio.session import AsyncSession 6 | 7 | from usagipass.app.database import require_session 8 | from usagipass.app.models import AccountServer, User, UserAccount 9 | 10 | router = APIRouter() 11 | 12 | 13 | class UserAccountSensitive(SQLModel): 14 | account_name: str 15 | account_server: AccountServer 16 | account_password: str 17 | 18 | 19 | async def require_token(token: str, session: AsyncSession = Depends(require_session)): 20 | if all(c in string.hexdigits for c in token): 21 | if user := (await session.exec(select(User).where(User.api_token == token))).first(): 22 | return user 23 | raise HTTPException(status_code=403, detail="Token 无效或已过期") 24 | raise HTTPException(status_code=400, detail="Token 不是有效的十六进制字符串") 25 | 26 | 27 | @router.get("/accounts", response_model=list[UserAccountSensitive]) 28 | async def get_accounts(user: User = Depends(require_token), session: AsyncSession = Depends(require_session)): 29 | accounts = await session.exec(select(UserAccount).where(UserAccount.username == user.username)) 30 | return [UserAccountSensitive.model_validate(account) for account in accounts] 31 | -------------------------------------------------------------------------------- /usagipass/app/database.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import contextlib 3 | from typing import AsyncGenerator, Generator, TypeVar 4 | from urllib.parse import unquote, urlparse 5 | 6 | import httpx 7 | from aiocache import RedisCache 8 | from aiocache.serializers import PickleSerializer 9 | from fastapi import Request 10 | from maimai_py import MaimaiClient 11 | from maimai_py.utils.sentinel import UNSET 12 | from sqlalchemy.exc import OperationalError 13 | from sqlalchemy.ext.asyncio import create_async_engine 14 | from sqlalchemy.pool import AsyncAdaptedQueuePool, QueuePool 15 | from sqlmodel import Session, SQLModel, create_engine 16 | from sqlmodel.ext.asyncio.session import AsyncSession 17 | 18 | from usagipass.app import settings 19 | from usagipass.app.logging import Ansi, log 20 | 21 | engine = create_engine(settings.mysql_url, poolclass=QueuePool) 22 | async_engine = create_async_engine(settings.mysql_url.replace("mysql+pymysql", "mysql+aiomysql"), poolclass=AsyncAdaptedQueuePool) 23 | redis_backend = UNSET 24 | if settings.redis_url: 25 | redis_url = urlparse(settings.redis_url) 26 | redis_backend = RedisCache( 27 | serializer=PickleSerializer(), 28 | endpoint=unquote(redis_url.hostname or "localhost"), 29 | port=redis_url.port or 6379, 30 | password=redis_url.password, 31 | db=int(unquote(redis_url.path).replace("/", "")), 32 | ) 33 | maimai_client = MaimaiClient(cache=redis_backend) 34 | httpx_client = httpx.AsyncClient(proxy=settings.httpx_proxy, timeout=20) 35 | 36 | V = TypeVar("V") 37 | 38 | 39 | @contextlib.asynccontextmanager 40 | async def async_session_ctx() -> AsyncGenerator[AsyncSession, None]: 41 | async with AsyncSession(async_engine, expire_on_commit=False) as session: 42 | yield session 43 | 44 | 45 | @contextlib.contextmanager 46 | def session_ctx() -> Generator[Session, None, None]: 47 | with Session(engine, expire_on_commit=False) as session: 48 | yield session 49 | 50 | 51 | def init_db(skip_migration: bool = False) -> None: 52 | from sqlalchemy import text 53 | import alembic.command as command 54 | from alembic.config import Config as AlembicConfig 55 | 56 | try: 57 | with session_ctx() as session: 58 | session.execute(text("SELECT 1")) 59 | except OperationalError: 60 | log("Failed to connect to the database.", Ansi.LRED) 61 | if not skip_migration: 62 | try: 63 | with engine.connect() as connection: 64 | result1 = connection.execute(text("SHOW TABLES LIKE 'alembic_version'")) 65 | result2 = connection.execute(text("SHOW TABLES LIKE 'users'")) 66 | if not result1.fetchone() and result2.fetchone(): 67 | # If alembic_version table does not exist but users table does, then the database is outdated 68 | log("You are running an outdated database schema. Running migration...", Ansi.YELLOW) 69 | command.stamp(AlembicConfig(config_args={"script_location": "alembic"}), "9cdcf6f8ca8c") 70 | command.upgrade(AlembicConfig(config_args={"script_location": "alembic"}), "head") 71 | except Exception as e: 72 | log(f"Failed to run database migration: {e}", Ansi.LRED) 73 | 74 | 75 | # https://stackoverflow.com/questions/75487025/how-to-avoid-creating-multiple-sessions-when-using-fastapi-dependencies-with-sec 76 | def register_middleware(asgi_app): 77 | @asgi_app.middleware("http") 78 | async def session_middleware(request: Request, call_next): 79 | async with async_session_ctx() as session: 80 | request.state.session = session 81 | response = await call_next(request) 82 | return response 83 | 84 | 85 | def require_session(request: Request): 86 | return request.state.session 87 | 88 | 89 | async def add_model(session: AsyncSession, *models): 90 | [session.add(model) for model in models if model] 91 | await session.flush() 92 | await asyncio.gather(*[session.refresh(model) for model in models if model]) 93 | 94 | 95 | async def partial_update_model(session: AsyncSession, item: SQLModel, updates: SQLModel): 96 | if item and updates: 97 | update_data = updates.model_dump(exclude_unset=True) 98 | for key, value in update_data.items(): 99 | setattr(item, key, value) 100 | await session.flush() 101 | await session.refresh(item) 102 | -------------------------------------------------------------------------------- /usagipass/app/entrypoint.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import logging 5 | from contextlib import asynccontextmanager 6 | 7 | from fastapi import FastAPI 8 | from mitmproxy.addons import default_addons 9 | from mitmproxy.master import Master 10 | from mitmproxy.options import Options 11 | from starlette.middleware.cors import CORSMiddleware 12 | 13 | from usagipass.app import database, settings 14 | from usagipass.app.database import maimai_client 15 | from usagipass.app.logging import Ansi, log 16 | from usagipass.app.usecases.addons import WechatWahlapAddon 17 | 18 | 19 | class MitmMaster(Master): 20 | def __init__(self): 21 | super().__init__(Options()) 22 | self.addons.add(*default_addons()) 23 | self.addons.add(WechatWahlapAddon()) 24 | self.options.update( 25 | listen_host=settings.mitm_host, 26 | listen_port=settings.mitm_port, 27 | block_global=False, 28 | connection_strategy="lazy", 29 | ) 30 | 31 | async def run(self): 32 | log(f"Mitmproxy running on http://{settings.mitm_host}:{str(settings.mitm_port)} (Press CTRL+C to quit)", Ansi.LCYAN) 33 | return await super().run() 34 | 35 | def _asyncio_exception_handler(self, loop, context): 36 | exc: Exception = context["exception"] 37 | logging.exception(exc) 38 | return super()._asyncio_exception_handler(loop, context) 39 | 40 | 41 | def init_middlewares(asgi_app: FastAPI) -> None: 42 | origins = [ 43 | "http://localhost:5173", 44 | "http://localhost:3000", 45 | ] 46 | 47 | asgi_app.add_middleware( 48 | CORSMiddleware, 49 | allow_origins=origins, 50 | allow_credentials=True, 51 | allow_methods=["*"], 52 | allow_headers=["*"], 53 | ) 54 | 55 | database.register_middleware(asgi_app) 56 | 57 | 58 | @asynccontextmanager 59 | async def init_lifespan(asgi_app: FastAPI): 60 | database.init_db() 61 | asyncio.create_task(MitmMaster().run()) 62 | asyncio.create_task(maimai_client.songs(curve_provider=None)) 63 | log("Startup process complete.", Ansi.LGREEN) 64 | yield # Above: Startup process Below: Shutdown process 65 | await database.async_engine.dispose() 66 | 67 | 68 | def init_routes(asgi_app: FastAPI) -> None: 69 | from usagipass.app import api 70 | 71 | @asgi_app.get("/", include_in_schema=False) 72 | async def root(): 73 | return {"message": "Welcome to UsagiPass backend!"} 74 | 75 | asgi_app.include_router(api.router) 76 | 77 | 78 | def init_api() -> FastAPI: 79 | """Create & initialize our app.""" 80 | asgi_app = FastAPI(lifespan=init_lifespan) 81 | 82 | init_middlewares(asgi_app) 83 | init_routes(asgi_app) 84 | 85 | return asgi_app 86 | 87 | 88 | asgi_app = init_api() 89 | -------------------------------------------------------------------------------- /usagipass/app/logging.py: -------------------------------------------------------------------------------- 1 | # Include from osuAkatsuki/bancho.py (MIT license) 2 | 3 | import colorsys 4 | import datetime 5 | from enum import IntEnum 6 | from typing import Optional 7 | from typing import Union 8 | from zoneinfo import ZoneInfo 9 | 10 | 11 | class Ansi(IntEnum): 12 | # Default colours 13 | BLACK = 30 14 | RED = 31 15 | GREEN = 32 16 | YELLOW = 33 17 | BLUE = 34 18 | MAGENTA = 35 19 | CYAN = 36 20 | WHITE = 37 21 | 22 | # Light colours 23 | GRAY = 90 24 | LRED = 91 25 | LGREEN = 92 26 | LYELLOW = 93 27 | LBLUE = 94 28 | LMAGENTA = 95 29 | LCYAN = 96 30 | LWHITE = 97 31 | 32 | RESET = 0 33 | 34 | def __repr__(self) -> str: 35 | return f"\x1b[{self.value}m" 36 | 37 | 38 | class RGB: 39 | def __init__(self, *args) -> None: 40 | largs = len(args) 41 | 42 | if largs == 3: 43 | # r, g, b passed. 44 | self.r, self.g, self.b = args 45 | elif largs == 1: 46 | # passed as single argument 47 | rgb = args[0] 48 | self.b = rgb & 0xFF 49 | self.g = (rgb >> 8) & 0xFF 50 | self.r = (rgb >> 16) & 0xFF 51 | else: 52 | raise ValueError("Incorrect params for RGB.") 53 | 54 | def __repr__(self) -> str: 55 | return f"\x1b[38;2;{self.r};{self.g};{self.b}m" 56 | 57 | 58 | class _Rainbow: ... 59 | 60 | 61 | Rainbow = _Rainbow() 62 | 63 | Colour_Types = Union[Ansi, RGB, _Rainbow] 64 | 65 | 66 | def get_timestamp(full: bool = False, tz: Optional[datetime.tzinfo] = None) -> str: 67 | fmt = "%d/%m/%Y %I:%M:%S%p" if full else "%I:%M:%S%p" 68 | return f"{datetime.datetime.now(tz=tz):{fmt}}" 69 | 70 | 71 | _log_tz = ZoneInfo("GMT") # default 72 | 73 | 74 | def set_timezone(tz: datetime.tzinfo) -> None: 75 | global _log_tz 76 | _log_tz = tz 77 | 78 | 79 | def printc(msg: str, col: Colour_Types, end: str = "\n") -> None: 80 | """Print a string, in a specified ansi colour.""" 81 | print(f"{col!r}{msg}{Ansi.RESET!r}", end=end) 82 | 83 | 84 | def log( 85 | msg: str, 86 | col: Optional[Colour_Types] = None, 87 | file: Optional[str] = None, 88 | end: str = "\n", 89 | ) -> None: 90 | """\ 91 | Print a string, in a specified ansi colour with timestamp. 92 | 93 | Allows for the functionality to write to a file as 94 | well by passing the filepath with the `file` parameter. 95 | """ 96 | 97 | ts_short = get_timestamp(full=False, tz=_log_tz) 98 | 99 | if col: 100 | if col is Rainbow: 101 | print(f"{Ansi.GRAY!r}[{ts_short}] {_fmt_rainbow(msg, 2/3)}", end=end) 102 | print(f"{Ansi.GRAY!r}[{ts_short}] {_fmt_rainbow(msg, 2/3)}", end=end) 103 | else: 104 | # normal colour 105 | print(f"{Ansi.GRAY!r}[{ts_short}] {col!r}{msg}{Ansi.RESET!r}", end=end) 106 | else: 107 | print(f"{Ansi.GRAY!r}[{ts_short}]{Ansi.RESET!r} {msg}", end=end) 108 | 109 | if file: 110 | # log simple ascii output to file. 111 | with open(file, "a+") as f: 112 | f.write(f"[{get_timestamp(full=True, tz=_log_tz)}] {msg}\n") 113 | 114 | 115 | def rainbow_color_stops( 116 | n: int = 10, 117 | lum: float = 0.5, 118 | end: float = 2 / 3, 119 | ) -> list[tuple[float, float, float]]: 120 | return [(r * 255, g * 255, b * 255) for r, g, b in [colorsys.hls_to_rgb(end * i / (n - 1), lum, 1) for i in range(n)]] 121 | 122 | 123 | def _fmt_rainbow(msg: str, end: float = 2 / 3) -> str: 124 | cols = [RGB(*map(int, rgb)) for rgb in rainbow_color_stops(n=len(msg), end=end)] 125 | return "".join([f"{cols[i]!r}{c}" for i, c in enumerate(msg)]) + repr(Ansi.RESET) 126 | 127 | 128 | def print_rainbow(msg: str, rainbow_end: float = 2 / 3, end: str = "\n") -> None: 129 | print(_fmt_rainbow(msg, rainbow_end), end=end) 130 | 131 | 132 | TIME_ORDER_SUFFIXES = ["nsec", "μsec", "msec", "sec"] 133 | 134 | 135 | def magnitude_fmt_time(t: Union[int, float]) -> str: # in nanosec 136 | suffix = "" 137 | for suffix in TIME_ORDER_SUFFIXES: 138 | if t < 1000: 139 | break 140 | t /= 1000 141 | return f"{t:.2f} {suffix}" 142 | -------------------------------------------------------------------------------- /usagipass/app/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import IntEnum, auto 3 | from sqlmodel import Field, SQLModel 4 | 5 | convention = { 6 | "ix": "ix_%(column_0_label)s", 7 | "uq": "uq_%(table_name)s_%(column_0_name)s", 8 | "ck": "ck_%(table_name)s_%(constraint_name)s", 9 | "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", 10 | "pk": "pk_%(table_name)s", 11 | } 12 | 13 | image_kinds = { 14 | "background": {"hw": [(768, 1052)]}, 15 | "frame": {"hw": [(768, 1052)]}, 16 | "character": {"hw": [(768, 1052), (1024, 1408)]}, 17 | "mask": {"hw": [(768, 1052), (1024, 1408)]}, 18 | "passname": {"hw": [(338, 112), (363, 110), (374, 105), (415, 115)]}, 19 | } 20 | 21 | sega_prefixs = ["UI_CardChara_", "UI_CardBase_", "UI_CMA_", "UI_CardCharaMask_"] 22 | 23 | SQLModel.metadata.naming_convention = convention 24 | 25 | 26 | class AccountServer(IntEnum): 27 | DIVING_FISH = auto() # 水鱼查分器 28 | LXNS = auto() # 落雪咖啡屋 29 | WECHAT = auto() # 微信小程序 30 | 31 | 32 | class Privilege(IntEnum): 33 | BANNED = auto() 34 | NORMAL = auto() 35 | ADMIN = auto() 36 | 37 | 38 | class Image(SQLModel, table=True): 39 | __tablename__ = "images" # type: ignore 40 | 41 | id: str = Field(primary_key=True) 42 | name: str 43 | kind: str 44 | sega_name: str | None = Field(default=None, index=True) 45 | uploaded_by: str | None = Field(default=None) 46 | uploaded_at: datetime = Field(default_factory=datetime.utcnow) 47 | 48 | 49 | class ImagePublic(SQLModel): 50 | id: str 51 | name: str 52 | kind: str 53 | uploaded_by: str | None 54 | 55 | 56 | class User(SQLModel, table=True): 57 | __tablename__ = "users" # type: ignore 58 | 59 | username: str = Field(primary_key=True) 60 | prefer_server: AccountServer 61 | api_token: str | None = Field(default=None, index=True, unique=True) 62 | privilege: Privilege = Field(default=Privilege.NORMAL, sa_column_kwargs={"server_default": "NORMAL"}) 63 | created_at: datetime = Field(default_factory=datetime.utcnow) 64 | updated_at: datetime = Field(default_factory=datetime.utcnow) 65 | 66 | 67 | class UserUpdate(SQLModel): 68 | prefer_server: AccountServer | None = None 69 | 70 | 71 | class UserAccount(SQLModel, table=True): 72 | __tablename__ = "user_accounts" # type: ignore 73 | 74 | account_name: str = Field(primary_key=True) 75 | account_server: AccountServer = Field(primary_key=True) 76 | account_password: str 77 | nickname: str 78 | bind_qq: str = Field(default="") 79 | player_rating: int = Field(default=10000) 80 | username: str = Field(index=True) 81 | created_at: datetime = Field(default_factory=datetime.utcnow) 82 | updated_at: datetime = Field(default_factory=datetime.utcnow) 83 | 84 | 85 | class UserAccountPublic(SQLModel): 86 | account_name: str 87 | nickname: str 88 | player_rating: int 89 | 90 | 91 | class PreferenceBase(SQLModel): 92 | maimai_version: str | None = None 93 | simplified_code: str | None = None 94 | character_name: str | None = None 95 | friend_code: str | None = None 96 | display_name: str | None = None 97 | dx_rating: str | None = None 98 | qr_size: int = Field(default=15) 99 | mask_type: int = Field(default=0) 100 | chara_info_color: str = Field(default="#fee37c", sa_column_kwargs={"server_default": "#fee37c"}) 101 | 102 | 103 | class UserPreference(PreferenceBase, table=True): 104 | __tablename__ = "user_preferences" # type: ignore 105 | 106 | username: str = Field(primary_key=True, foreign_key="users.username") 107 | character_id: str | None = Field(default=None, foreign_key="images.id", ondelete="SET NULL") 108 | background_id: str | None = Field(default=None, foreign_key="images.id", ondelete="SET NULL") 109 | frame_id: str | None = Field(default=None, foreign_key="images.id", ondelete="SET NULL") 110 | passname_id: str | None = Field(default=None, foreign_key="images.id", ondelete="SET NULL") 111 | 112 | 113 | class PreferencePublic(PreferenceBase): 114 | character: ImagePublic | None = None 115 | background: ImagePublic | None = None 116 | frame: ImagePublic | None = None 117 | passname: ImagePublic | None = None 118 | 119 | 120 | class PreferenceUpdate(PreferenceBase): 121 | character_id: str | None = None 122 | background_id: str | None = None 123 | frame_id: str | None = None 124 | passname_id: str | None = None 125 | 126 | 127 | class UserProfile(SQLModel): 128 | username: str 129 | api_token: str 130 | prefer_server: AccountServer 131 | privilege: Privilege 132 | preferences: PreferencePublic 133 | accounts: dict[AccountServer, UserAccountPublic] 134 | 135 | 136 | class CrawlerResult(SQLModel): 137 | account_server: int = AccountServer.WECHAT 138 | success: bool = False 139 | scores_num: int = 0 140 | from_rating: int = 0 141 | to_rating: int = 0 142 | err_msg: str = "" 143 | elapsed_time: float = 0.0 144 | 145 | 146 | class Announcement(SQLModel, table=True): 147 | __tablename__ = "announcements" # type: ignore 148 | 149 | id: int | None = Field(default=None, primary_key=True) 150 | title: str = Field(index=True) 151 | content: str 152 | is_active: bool = Field(default=True) 153 | created_at: datetime = Field(default_factory=datetime.utcnow) 154 | updated_at: datetime = Field(default_factory=datetime.utcnow) 155 | 156 | 157 | class AnnouncementRead(SQLModel, table=True): 158 | __tablename__ = "announcement_reads" # type: ignore 159 | 160 | id: int | None = Field(default=None, primary_key=True) 161 | announcement_id: int = Field(foreign_key="announcements.id", index=True, ondelete="CASCADE") 162 | username: str = Field(foreign_key="users.username", index=True, ondelete="CASCADE") 163 | read_at: datetime = Field(default_factory=datetime.utcnow) 164 | 165 | class Config: # type: ignore 166 | unique_together = [("announcement_id", "username")] 167 | 168 | 169 | class AnnouncementCreate(SQLModel): 170 | title: str 171 | content: str 172 | is_active: bool = True 173 | 174 | 175 | class AnnouncementUpdate(SQLModel): 176 | title: str | None = None 177 | content: str | None = None 178 | is_active: bool | None = None 179 | 180 | 181 | class AnnouncementPublic(SQLModel): 182 | id: int 183 | title: str 184 | content: str 185 | is_active: bool 186 | created_at: datetime 187 | updated_at: datetime 188 | is_read: bool = False 189 | -------------------------------------------------------------------------------- /usagipass/app/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | 4 | load_dotenv() 5 | 6 | # uvicorn settings 7 | app_host = os.environ.get("APP_HOST", "127.0.0.1") 8 | app_port = int(os.environ.get("APP_PORT", 8000)) 9 | app_root = os.environ.get("APP_ROOT", "") 10 | app_root = "" if app_root == "/" else app_root 11 | app_url = os.environ.get("APP_URL", "https://up.turou.fun/") 12 | 13 | # mitmproxy settings 14 | mitm_host = os.environ.get("MITM_HOST", "0.0.0.0") 15 | mitm_port = int(os.environ.get("MITM_PORT", 2560)) 16 | 17 | # database settings 18 | mysql_url = os.environ["MYSQL_URL"] 19 | redis_url = os.environ.get("REDIS_URL", None) or None 20 | jwt_secret = os.environ.get("JWT_SECRET") or "change_me_in_production_enviroment" 21 | httpx_proxy = os.environ.get("HTTPX_PROXY", None) or None 22 | arcade_proxy = os.environ.get("ARCADE_PROXY", None) or None 23 | 24 | # provider settings 25 | lxns_developer_token = os.environ.get("LXNS_DEVELOPER_TOKEN", None) or None 26 | divingfish_developer_token = os.environ.get("DIVINGFISH_DEVELOPER_TOKEN", None) or None 27 | 28 | default_character = os.environ.get("DEFAULT_CHARACTER", "default") 29 | default_background = os.environ.get("DEFAULT_BACKGROUND", "default") 30 | default_frame = os.environ.get("DEFAULT_FRAME", "default") 31 | default_passname = os.environ.get("DEFAULT_PASSNAME", "default") 32 | 33 | # refresh settings 34 | refresh_hour_threshold = int(os.environ.get("REFRESH_HOUR_THRESHOLD", 24)) 35 | refresh_hour_active = os.environ.get("REFRESH_HOUR_ACTIVE", "10-17") 36 | refresh_hour_inactive = os.environ.get("REFRESH_HOUR_INACTIVE", "0-9,18-23") 37 | -------------------------------------------------------------------------------- /usagipass/app/usecases/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrueRou/UsagiPass/e54154e76c6b1fb9b945718e875a2c63708f9814/usagipass/app/usecases/__init__.py -------------------------------------------------------------------------------- /usagipass/app/usecases/accounts.py: -------------------------------------------------------------------------------- 1 | from fastapi import HTTPException, status 2 | from httpx import ConnectError, ReadTimeout 3 | from sqlmodel.ext.asyncio.session import AsyncSession 4 | 5 | from usagipass.app.database import httpx_client 6 | from usagipass.app.models import AccountServer, Image, ImagePublic, PreferencePublic, User, UserAccount, UserPreference 7 | from usagipass.app.settings import default_background, default_character, default_frame, default_passname 8 | from usagipass.app.usecases.crawler import fetch_rating_retry 9 | 10 | 11 | async def apply_preference(preferences: PreferencePublic, db_preferences: UserPreference, session: AsyncSession): 12 | # we need to get the image objects from the database 13 | character = await session.get(Image, db_preferences.character_id or default_character) 14 | background = await session.get(Image, db_preferences.background_id or default_background) 15 | frame = await session.get(Image, db_preferences.frame_id or default_frame) 16 | passname = await session.get(Image, db_preferences.passname_id or default_passname) 17 | if None in [character, background, frame, passname]: 18 | raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="默认图片未在数据库中找到,请联系开发者") 19 | preferences.character = ImagePublic.model_validate(character) 20 | preferences.background = ImagePublic.model_validate(background) 21 | preferences.frame = ImagePublic.model_validate(frame) 22 | preferences.passname = ImagePublic.model_validate(passname) 23 | 24 | 25 | async def auth_divingfish(account_name: str, account_password: str) -> dict: 26 | try: 27 | json = {"username": account_name, "password": account_password} 28 | response = await httpx_client.post("https://www.diving-fish.com/api/maimaidxprober/login", json=json) 29 | if "errcode" in response.json(): 30 | raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="水鱼查分器用户名或密码错误") 31 | profile = (await httpx_client.get("https://www.diving-fish.com/api/maimaidxprober/player/profile", cookies=response.cookies)).json() 32 | return profile 33 | except (ConnectError, ReadTimeout): 34 | raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="无法连接到水鱼查分器服务") 35 | 36 | 37 | async def auth_lxns(personal_token: str) -> dict: 38 | try: 39 | headers = {"X-User-Token": personal_token} 40 | response = (await httpx_client.get("https://maimai.lxns.net/api/v0/user/maimai/player", headers=headers)).json() 41 | if not response["success"]: 42 | raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="落雪服务个人令牌错误") 43 | return response["data"] 44 | except (ConnectError, ReadTimeout): 45 | raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="无法连接到落雪查分器服务") 46 | 47 | 48 | async def merge_user(session: AsyncSession, account_name: str, server: AccountServer) -> User: 49 | account = await session.get(UserAccount, (account_name, server)) 50 | if account and (user := await session.get(User, account.username)): 51 | user.prefer_server = server 52 | return user 53 | else: 54 | user = User(username=account_name, prefer_server=server) 55 | return await session.merge(user) 56 | 57 | 58 | async def merge_divingfish(session: AsyncSession, user: User, account_name: str, account_password: str) -> UserAccount: 59 | account = await session.get(UserAccount, (account_name, AccountServer.DIVING_FISH)) 60 | if account and account.username != user.username: 61 | raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"该水鱼账号已被其他账号 {user.username} 绑定") 62 | profile = await auth_divingfish(account_name, account_password) 63 | new_account = UserAccount( 64 | account_name=account_name, 65 | account_server=AccountServer.DIVING_FISH, 66 | account_password=account_password, 67 | username=user.username, 68 | nickname=profile["nickname"], 69 | bind_qq=profile["bind_qq"], 70 | ) 71 | new_account.player_rating = await fetch_rating_retry(new_account) 72 | await session.merge(new_account) 73 | return new_account 74 | 75 | 76 | async def merge_lxns(session: AsyncSession, user: User, personal_token: str) -> UserAccount: 77 | profile = await auth_lxns(personal_token) 78 | account_name = str(profile["friend_code"]) 79 | account = await session.get(UserAccount, (account_name, AccountServer.LXNS)) 80 | if account and account.username != user.username: 81 | raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"该落雪账号已被其他账号 {user.username} 绑定") 82 | new_account = UserAccount( 83 | account_name=account_name, 84 | account_server=AccountServer.LXNS, 85 | account_password=personal_token, 86 | username=user.username, 87 | nickname=profile["name"], 88 | ) 89 | new_account.player_rating = await fetch_rating_retry(new_account) 90 | await session.merge(new_account) 91 | return new_account 92 | -------------------------------------------------------------------------------- /usagipass/app/usecases/addons.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from mitmproxy.http import HTTPFlow, Response 3 | 4 | from usagipass.app import settings 5 | 6 | 7 | class WechatWahlapAddon: 8 | sysall_hosts = [ 9 | "42.193.74.107", 10 | "129.28.248.89", 11 | "43.137.91.207", 12 | "81.71.193.236", 13 | "43.145.45.124", 14 | "wq.sys-all.cn", 15 | "wq.sys-allnet.cn", 16 | ] 17 | 18 | wahlap_hosts = [ 19 | "152.136.21.46", 20 | "tgk-wcaime.wahlap.com", 21 | ] 22 | 23 | async def request(self, flow: HTTPFlow): 24 | # redirect sysall qrcode requests to the usagi pass frontend 25 | # example: http://wq.sys-all.cn/qrcode/req/MAID241020A01.html?l=1730217600&t=E8889E 26 | if flow.request.host in self.sysall_hosts and flow.request.path.find("qrcode") != -1 and flow.request.path.find("req") != -1: 27 | maid = flow.request.path_components[2].replace(".html", "") 28 | timestamp = int(flow.request.query.get("l") or 0) 29 | timestr = datetime.fromtimestamp(timestamp).strftime("%H:%M:%S") 30 | location = settings.app_url + f"?maid={maid}&time={timestr}" 31 | flow.response = Response.make(302, headers={"Location": location}) 32 | 33 | # response wahlap mitm connection test 34 | # example: http://tgk-wcaime.wahlap.com/test 35 | elif flow.request.host in self.wahlap_hosts and flow.request.path == "/test": 36 | flow.response = Response.make(200, content=b'{"source": "UsagiPass", "proxy":"ok"}') 37 | flow.response.headers["Access-Control-Allow-Origin"] = "*" 38 | 39 | # redirect wahlap oauth requests to the usagi pass frontend 40 | # example: http://tgk-wcaime.wahlap.com/wc_auth/oauth/callback/maimai-dx?r=c9N1mMeLT&t=241114354&code=071EIC0003YUbTf5X31EIC0p&state=24F0976C60BD9796310AD933AFEF39FFCD7C0E64E9571E69A5AE5 41 | elif flow.request.host in self.wahlap_hosts and flow.request.path.startswith("/wc_auth/oauth/callback/maimai-dx"): 42 | location = settings.app_url + "update" + flow.request.path.removeprefix("/wc_auth/oauth/callback/maimai-dx") 43 | flow.response = Response.make(302, headers={"Location": location}) 44 | 45 | # block all other requests 46 | else: 47 | flow.response = Response.make(204) 48 | -------------------------------------------------------------------------------- /usagipass/app/usecases/authorize.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | import jwt 4 | from fastapi import Depends, HTTPException, status 5 | from fastapi.security import OAuth2PasswordBearer 6 | from sqlmodel.ext.asyncio.session import AsyncSession 7 | 8 | from usagipass.app.database import require_session 9 | from usagipass.app.models import Privilege, User 10 | from usagipass.app.settings import jwt_secret 11 | 12 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="accounts/token/divingfish") 13 | optional_oauth2_scheme = OAuth2PasswordBearer(tokenUrl="accounts/token/divingfish", auto_error=False) 14 | 15 | 16 | def grant_user(user: User): 17 | token = jwt.encode({"username": user.username}, jwt_secret, algorithm="HS256") 18 | return {"access_token": token, "token_type": "bearer"} 19 | 20 | 21 | async def verify_admin(token: Annotated[str, Depends(oauth2_scheme)], session: AsyncSession = Depends(require_session)): 22 | try: 23 | payload = jwt.decode(token, jwt_secret, algorithms=["HS256"]) 24 | if (user := await session.get(User, payload["username"])) and user.privilege == Privilege.ADMIN: 25 | return user 26 | raise HTTPException( 27 | status_code=status.HTTP_403_FORBIDDEN, 28 | detail="您不是管理员", 29 | ) 30 | except jwt.InvalidTokenError: 31 | raise HTTPException( 32 | status_code=status.HTTP_401_UNAUTHORIZED, 33 | detail="无效的身份验证凭据", 34 | headers={"WWW-Authenticate": "Bearer"}, 35 | ) 36 | 37 | 38 | async def verify_user(token: Annotated[str, Depends(oauth2_scheme)], session: AsyncSession = Depends(require_session)) -> User: 39 | try: 40 | payload = jwt.decode(token, jwt_secret, algorithms=["HS256"]) 41 | if user := await session.get(User, payload["username"]): 42 | return user 43 | raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在或已被禁用") 44 | except jwt.InvalidTokenError: 45 | raise HTTPException( 46 | status_code=status.HTTP_401_UNAUTHORIZED, 47 | detail="无效的身份验证凭据", 48 | headers={"WWW-Authenticate": "Bearer"}, 49 | ) 50 | 51 | 52 | async def verify_user_optional( 53 | token: Annotated[str | None, Depends(optional_oauth2_scheme)], session: AsyncSession = Depends(require_session) 54 | ) -> User | None: 55 | try: 56 | if token and (payload := jwt.decode(token, jwt_secret, algorithms=["HS256"])): 57 | if user := await session.get(User, payload["username"]): 58 | return user 59 | except jwt.InvalidTokenError: 60 | pass 61 | -------------------------------------------------------------------------------- /usagipass/app/usecases/crawler.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import time 3 | import traceback 4 | from datetime import datetime 5 | 6 | from httpx import Cookies 7 | from maimai_py import DivingFishProvider, LXNSProvider, MaimaiScores, PlayerIdentifier, WechatProvider 8 | from maimai_py.models import Player, Score 9 | from sqlmodel import select 10 | from sqlmodel.ext.asyncio.session import AsyncSession 11 | from tenacity import retry, stop_after_attempt 12 | 13 | from usagipass.app.database import async_session_ctx, maimai_client 14 | from usagipass.app.logging import Ansi, log 15 | from usagipass.app.models import AccountServer, CrawlerResult, User, UserAccount 16 | from usagipass.app.settings import divingfish_developer_token, lxns_developer_token 17 | 18 | 19 | @retry(stop=stop_after_attempt(3), reraise=True) 20 | async def fetch_wechat_retry(cookies: Cookies) -> MaimaiScores: 21 | return await maimai_client.scores(PlayerIdentifier(credentials=cookies), provider=WechatProvider()) 22 | 23 | 24 | @retry(stop=stop_after_attempt(3)) 25 | async def fetch_rating_retry(account: UserAccount) -> int: 26 | ident, provider = None, None 27 | if account.account_server == AccountServer.DIVING_FISH: 28 | ident = PlayerIdentifier(username=account.account_name) 29 | provider = DivingFishProvider(divingfish_developer_token) 30 | elif account.account_server == AccountServer.LXNS: 31 | ident = PlayerIdentifier(friend_code=int(account.account_name)) 32 | provider = LXNSProvider(lxns_developer_token) 33 | assert ident and provider, "Invalid account server" 34 | player: Player = await maimai_client.players(ident, provider) 35 | return player.rating 36 | 37 | 38 | @retry(stop=stop_after_attempt(3)) 39 | async def upload_server_retry(account: UserAccount, scores: list[Score]): 40 | ident, provider = None, None 41 | if account.account_server == AccountServer.DIVING_FISH: 42 | ident = PlayerIdentifier(username=account.account_name, credentials=account.account_password) 43 | provider = DivingFishProvider(divingfish_developer_token) 44 | elif account.account_server == AccountServer.LXNS: 45 | ident = PlayerIdentifier(credentials=account.account_password) 46 | provider = LXNSProvider(lxns_developer_token) 47 | assert ident and provider, "Invalid account server" 48 | await maimai_client.updates(ident, scores, provider) 49 | 50 | 51 | async def fetch_wechat(username: str, cookies: Cookies) -> tuple[list[Score], CrawlerResult]: 52 | try: 53 | scores = await fetch_wechat_retry(cookies) 54 | result = CrawlerResult( 55 | account_server=AccountServer.WECHAT, 56 | success=True, 57 | scores_num=len(scores.scores), 58 | ) 59 | distinct_scores = await scores.get_distinct() 60 | return distinct_scores.scores, result 61 | except Exception as e: 62 | traceback.print_exc() 63 | log(f"Failed to fetch scores from wechat for {username}.", Ansi.LRED) 64 | return [], CrawlerResult( 65 | account_server=AccountServer.WECHAT, 66 | success=False, 67 | scores_num=0, 68 | err_msg=repr(e), 69 | ) 70 | 71 | 72 | async def update_rating(account: UserAccount, result: CrawlerResult = CrawlerResult()) -> CrawlerResult: 73 | async with async_session_ctx() as scoped_session: 74 | account = await scoped_session.get(UserAccount, (account.account_name, account.account_server)) or account 75 | result.from_rating = account.player_rating 76 | try: 77 | result.to_rating = await fetch_rating_retry(account) 78 | log( 79 | f"{account.username}({account.account_server.name} {account.account_name}) {result.from_rating} -> {result.to_rating})", 80 | Ansi.GREEN, 81 | ) 82 | except Exception as e: 83 | result.to_rating = result.from_rating 84 | traceback.print_exc() 85 | log( 86 | f"Failed to update rating for {account.username}({account.account_server.name} {account.account_name}).", 87 | Ansi.LRED, 88 | ) 89 | account.player_rating = result.to_rating 90 | account.updated_at = datetime.utcnow() 91 | await scoped_session.commit() 92 | return result 93 | 94 | 95 | async def upload_server(account: UserAccount, scores: list[Score]) -> CrawlerResult: 96 | try: 97 | begin = time.time() 98 | await upload_server_retry(account, scores) 99 | return CrawlerResult( 100 | account_server=account.account_server, 101 | success=True, 102 | scores_num=len(scores), 103 | elapsed_time=time.time() - begin, 104 | ) 105 | except Exception as e: 106 | traceback.print_exc() 107 | log( 108 | f"Failed to upload {account.account_server.name} for {account.username}.", 109 | Ansi.LRED, 110 | ) 111 | return CrawlerResult( 112 | account_server=account.account_server, 113 | success=False, 114 | scores_num=len(scores), 115 | err_msg=str(e), 116 | ) 117 | 118 | 119 | async def crawl_async(cookies: Cookies, user: User, session: AsyncSession) -> list[CrawlerResult]: 120 | accounts = (await session.exec(select(UserAccount).where(UserAccount.username == user.username))).all() 121 | begin = time.time() 122 | scores, result = await fetch_wechat(user.username, cookies) 123 | result.elapsed_time = time.time() - begin 124 | if result.success: 125 | uploads = await asyncio.gather( 126 | *[upload_server(account, scores) for account in accounts], 127 | return_exceptions=False, 128 | ) 129 | async with asyncio.TaskGroup() as tg: 130 | for account, upload in zip(accounts, uploads): 131 | tg.create_task(update_rating(account, upload)) 132 | return [result, *uploads] 133 | return [result] 134 | -------------------------------------------------------------------------------- /usagipass/app/usecases/maimai.py: -------------------------------------------------------------------------------- 1 | from asyncio import TaskGroup 2 | from datetime import datetime, timedelta 3 | 4 | from sqlmodel import select 5 | 6 | from usagipass.app.database import async_session_ctx 7 | from usagipass.app.models import UserAccount 8 | from usagipass.app.usecases import crawler 9 | 10 | 11 | # attempt to refresh the usagipass user's rating depends on check_delta 12 | async def update_rating_passive(username: str): 13 | async with async_session_ctx() as scoped_session: 14 | accounts = await scoped_session.exec(select(UserAccount).where(UserAccount.username == username)) 15 | async with TaskGroup() as tg: 16 | for account in accounts: 17 | if datetime.utcnow() - account.updated_at > timedelta(minutes=30): 18 | tg.create_task(crawler.update_rating(account)) 19 | -------------------------------------------------------------------------------- /usagipass/main.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import uvicorn 4 | 5 | from usagipass.app import settings 6 | from usagipass.app.logging import Ansi, log 7 | from usagipass.app.entrypoint import MitmMaster, asgi_app 8 | 9 | 10 | def main(): 11 | log(f"Uvicorn running on http://{settings.app_host}:{str(settings.app_port)} (Press CTRL+C to quit)", Ansi.YELLOW) 12 | uvicorn.run(asgi_app, log_level=logging.WARNING, port=settings.app_port, host=settings.app_host, root_path=settings.app_root) 13 | 14 | 15 | def mitm_main(): 16 | async def run_proxy_async(): 17 | master = MitmMaster() 18 | await master.run() 19 | 20 | log(f"Mitmproxy running on http://{settings.mitm_host}:{str(settings.mitm_port)} (Press CTRL+C to quit)", Ansi.LCYAN) 21 | asyncio.run(run_proxy_async()) 22 | 23 | 24 | if __name__ == "__main__": 25 | main() 26 | -------------------------------------------------------------------------------- /usagipass/tools/importer.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import io 3 | import uuid 4 | from pathlib import Path 5 | 6 | import httpx 7 | import PIL 8 | from PIL import Image as PILImage 9 | from PIL import ImageChops 10 | from PIL.ImageFile import ImageFile 11 | from sqlmodel import and_, select 12 | 13 | from usagipass.app.api.images import images_folder 14 | from usagipass.app.database import init_db, session_ctx 15 | from usagipass.app.logging import Ansi, log 16 | from usagipass.app.models import Image, image_kinds 17 | 18 | data_folder = Path.cwd() / ".data" 19 | import_folder = Path.cwd() / ".data" / "import" 20 | data_folder.mkdir(exist_ok=True) 21 | import_folder.mkdir(exist_ok=True) 22 | mask_base = PILImage.open(io.BytesIO(httpx.get("https://s2.loli.net/2024/11/01/gDCfokPySl32srL.png").content)) 23 | 24 | 25 | def _verify_kind(img: ImageFile, kind: str): 26 | if kind not in image_kinds: 27 | raise ValueError(f"Invalid kind of image.") 28 | 29 | for hw in image_kinds[kind]["hw"]: 30 | if img.width == hw[0] and img.height == hw[1]: 31 | return 32 | raise ValueError(f"Irregular width or height.") 33 | 34 | 35 | def _parse_dictionary(file: Path): 36 | result = {} 37 | if file.exists(): 38 | with open(file, "r", encoding="utf-8") as f: 39 | for line in f.read().splitlines(): 40 | file_str, name = line.split(" ", maxsplit=1) 41 | file1 = file_str.replace("cardChara0", "UI_CardChara_") 42 | result[file1] = name 43 | file2 = file_str.replace("cardChara00", "UI_CardChara_") # SBGA what are you doing? 44 | result[file2] = name 45 | return result 46 | 47 | 48 | def post_process(kind: str, img: PILImage.Image) -> PILImage.Image: 49 | if kind == "mask": 50 | result = PILImage.new("RGBA", img.size) 51 | result = PILImage.alpha_composite(result, mask_base.convert("RGBA")) 52 | result = PILImage.alpha_composite(result, img.convert("RGBA")) 53 | result = ImageChops.invert(result.convert("RGB")) 54 | result = result.convert("L").convert("RGBA") 55 | alpha = result.split()[0] 56 | result.putalpha(alpha) 57 | return result 58 | return img 59 | 60 | 61 | def import_images(kind: str): 62 | with session_ctx() as session: 63 | images_path = import_folder / kind 64 | list_path = import_folder / kind / "list.txt" 65 | success = 0 66 | failed = 0 67 | overwritten = 0 68 | 69 | if kind not in image_kinds.keys(): 70 | log(f"Invalid kind of image: {kind}", Ansi.LRED) 71 | return 72 | 73 | images_path.mkdir(exist_ok=True) # create the folder if it doesn't exist 74 | dictionary = _parse_dictionary(list_path) # load the dictionary 75 | 76 | for file in images_path.iterdir(): 77 | if file.is_file() and file.suffix in [".png", ".jpg", ".jpeg", ".webp"]: 78 | try: 79 | matched_name = dictionary.get(file.stem, file.stem) # try matching the name from the dictionary 80 | previous = session.exec(select(Image).where(and_(Image.sega_name == file.stem, Image.kind == kind))).first() 81 | # check if the image already exists (overwrite if exists) 82 | if previous: 83 | old_file = images_folder / f"{previous.id}.webp" 84 | session.delete(previous) # delete the previous entry 85 | old_file.unlink() # delete the old file 86 | log(f"Image {matched_name} of kind {kind} already exists, overwriting", Ansi.LYELLOW) 87 | overwritten += 1 88 | idx = str(uuid.uuid4()) 89 | img = PILImage.open(file) 90 | _verify_kind(img, kind) # check if the image is of the correct kind 91 | img = post_process(kind, img) # post-process the image for the specific kind to perform effects 92 | img.save(images_folder / f"{idx}.webp", "webp", optimize=True, quality=80) 93 | session.add(Image(id=idx, name=matched_name, sega_name=file.stem, kind=kind)) 94 | file.unlink() # delete the success file after importing 95 | success += 1 96 | except PIL.UnidentifiedImageError: 97 | failed += 1 98 | except ValueError: 99 | failed += 1 100 | session.commit() 101 | 102 | log(f"Imported {success + failed} images of {kind}, {success} success ({overwritten} overwritten) and {failed} failed", Ansi.LGREEN) 103 | 104 | 105 | async def main(): 106 | init_db(skip_migration=True) # ensure the database is created 107 | # Run all imports in parallel 108 | kinds = ["background", "frame", "character", "passname", "mask"] 109 | tasks = [asyncio.to_thread(import_images, kind) for kind in kinds] 110 | await asyncio.gather(*tasks) 111 | log("Import process complete.", Ansi.LGREEN) 112 | 113 | 114 | if __name__ == "__main__": 115 | asyncio.run(main()) 116 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | pnpm-debug.log* 10 | lerna-debug.log* 11 | 12 | node_modules 13 | .DS_Store 14 | dist 15 | dist-ssr 16 | coverage 17 | *.local 18 | 19 | /cypress/videos/ 20 | /cypress/screenshots/ 21 | 22 | # Editor directories and files 23 | .vscode/* 24 | !.vscode/extensions.json 25 | .idea 26 | *.suo 27 | *.ntvs* 28 | *.njsproj 29 | *.sln 30 | *.sw? 31 | 32 | *.tsbuildinfo 33 | -------------------------------------------------------------------------------- /web/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar"] 3 | } 4 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # frontend 2 | 3 | This template should help get you started developing with Vue 3 in Vite. 4 | 5 | ## Recommended IDE Setup 6 | 7 | [VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur). 8 | 9 | ## Type Support for `.vue` Imports in TS 10 | 11 | TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types. 12 | 13 | ## Customize configuration 14 | 15 | See [Vite Configuration Reference](https://vitejs.dev/config/). 16 | 17 | ## Project Setup 18 | 19 | ```sh 20 | npm install 21 | ``` 22 | 23 | ### Compile and Hot-Reload for Development 24 | 25 | ```sh 26 | npm run dev 27 | ``` 28 | 29 | ### Type-Check, Compile and Minify for Production 30 | 31 | ```sh 32 | npm run build 33 | ``` 34 | -------------------------------------------------------------------------------- /web/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module '*.vue' { 4 | import type { DefineComponent } from 'vue' 5 | import Vue from 'vue' 6 | 7 | const component: DefineComponent<{}, {}, any> | Vue 8 | 9 | export default component 10 | } -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Usagi Pass 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "run-p type-check \"build-only {@}\" --", 9 | "preview": "vite preview", 10 | "build-only": "vite build", 11 | "type-check": "vue-tsc --build --force" 12 | }, 13 | "dependencies": { 14 | "@types/markdown-it": "^14.1.2", 15 | "axios": "^1.7.7", 16 | "markdown-it": "^14.1.0", 17 | "pinia": "^2.1.7", 18 | "qrcode": "^1.5.4", 19 | "vue": "^3.4.29", 20 | "vue-cropper": "^1.1.4", 21 | "vue-router": "^4.3.3" 22 | }, 23 | "devDependencies": { 24 | "@tsconfig/node20": "^20.1.4", 25 | "@types/node": "^20.14.5", 26 | "@types/qrcode": "^1.5.5", 27 | "@vitejs/plugin-vue": "^5.0.5", 28 | "@vitejs/plugin-vue-jsx": "^4.0.0", 29 | "@vue/tsconfig": "^0.5.1", 30 | "autoprefixer": "^10.4.20", 31 | "npm-run-all2": "^6.2.0", 32 | "postcss": "^8.4.47", 33 | "tailwindcss": "^3.4.13", 34 | "typescript": "~5.4.0", 35 | "vite": "^5.3.1", 36 | "vue-tsc": "^2.0.21" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /web/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrueRou/UsagiPass/e54154e76c6b1fb9b945718e875a2c63708f9814/web/public/favicon.ico -------------------------------------------------------------------------------- /web/src/App.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 28 | -------------------------------------------------------------------------------- /web/src/assets/SEGAMaruGothicDB.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrueRou/UsagiPass/e54154e76c6b1fb9b945718e875a2c63708f9814/web/src/assets/SEGAMaruGothicDB.woff2 -------------------------------------------------------------------------------- /web/src/assets/base.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @font-face { 6 | font-family: "SEGA_MARUGOTHICDB"; 7 | src: url("./SEGAMaruGothicDB.woff2"); 8 | } 9 | 10 | .font-sega { 11 | font-family: "SEGA_MARUGOTHICDB"; 12 | } -------------------------------------------------------------------------------- /web/src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /web/src/assets/main.css: -------------------------------------------------------------------------------- 1 | @import './base.css'; 2 | -------------------------------------------------------------------------------- /web/src/assets/misc/UI_CMN_Name_DX.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrueRou/UsagiPass/e54154e76c6b1fb9b945718e875a2c63708f9814/web/src/assets/misc/UI_CMN_Name_DX.png -------------------------------------------------------------------------------- /web/src/assets/misc/UI_CardBase_Back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrueRou/UsagiPass/e54154e76c6b1fb9b945718e875a2c63708f9814/web/src/assets/misc/UI_CardBase_Back.png -------------------------------------------------------------------------------- /web/src/assets/misc/afdian.svg: -------------------------------------------------------------------------------- 1 | 6 | 11 | -------------------------------------------------------------------------------- /web/src/assets/misc/avatar.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrueRou/UsagiPass/e54154e76c6b1fb9b945718e875a2c63708f9814/web/src/assets/misc/avatar.webp -------------------------------------------------------------------------------- /web/src/assets/misc/github-mark-white.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/assets/misc/images.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/assets/misc/logout.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/assets/misc/refresh.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/assets/misc/settings.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.15, written by Peter Selinger 2001-2017 9 | 10 | 12 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /web/src/assets/rating/UI_CMA_Rating_Base_0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrueRou/UsagiPass/e54154e76c6b1fb9b945718e875a2c63708f9814/web/src/assets/rating/UI_CMA_Rating_Base_0.png -------------------------------------------------------------------------------- /web/src/assets/rating/UI_CMA_Rating_Base_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrueRou/UsagiPass/e54154e76c6b1fb9b945718e875a2c63708f9814/web/src/assets/rating/UI_CMA_Rating_Base_1.png -------------------------------------------------------------------------------- /web/src/assets/rating/UI_CMA_Rating_Base_10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrueRou/UsagiPass/e54154e76c6b1fb9b945718e875a2c63708f9814/web/src/assets/rating/UI_CMA_Rating_Base_10.png -------------------------------------------------------------------------------- /web/src/assets/rating/UI_CMA_Rating_Base_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrueRou/UsagiPass/e54154e76c6b1fb9b945718e875a2c63708f9814/web/src/assets/rating/UI_CMA_Rating_Base_2.png -------------------------------------------------------------------------------- /web/src/assets/rating/UI_CMA_Rating_Base_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrueRou/UsagiPass/e54154e76c6b1fb9b945718e875a2c63708f9814/web/src/assets/rating/UI_CMA_Rating_Base_3.png -------------------------------------------------------------------------------- /web/src/assets/rating/UI_CMA_Rating_Base_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrueRou/UsagiPass/e54154e76c6b1fb9b945718e875a2c63708f9814/web/src/assets/rating/UI_CMA_Rating_Base_4.png -------------------------------------------------------------------------------- /web/src/assets/rating/UI_CMA_Rating_Base_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrueRou/UsagiPass/e54154e76c6b1fb9b945718e875a2c63708f9814/web/src/assets/rating/UI_CMA_Rating_Base_5.png -------------------------------------------------------------------------------- /web/src/assets/rating/UI_CMA_Rating_Base_6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrueRou/UsagiPass/e54154e76c6b1fb9b945718e875a2c63708f9814/web/src/assets/rating/UI_CMA_Rating_Base_6.png -------------------------------------------------------------------------------- /web/src/assets/rating/UI_CMA_Rating_Base_7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrueRou/UsagiPass/e54154e76c6b1fb9b945718e875a2c63708f9814/web/src/assets/rating/UI_CMA_Rating_Base_7.png -------------------------------------------------------------------------------- /web/src/assets/rating/UI_CMA_Rating_Base_8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrueRou/UsagiPass/e54154e76c6b1fb9b945718e875a2c63708f9814/web/src/assets/rating/UI_CMA_Rating_Base_8.png -------------------------------------------------------------------------------- /web/src/assets/rating/UI_CMA_Rating_Base_9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrueRou/UsagiPass/e54154e76c6b1fb9b945718e875a2c63708f9814/web/src/assets/rating/UI_CMA_Rating_Base_9.png -------------------------------------------------------------------------------- /web/src/assets/rating/num/UI_CMN_Num_26p_0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrueRou/UsagiPass/e54154e76c6b1fb9b945718e875a2c63708f9814/web/src/assets/rating/num/UI_CMN_Num_26p_0.png -------------------------------------------------------------------------------- /web/src/assets/rating/num/UI_CMN_Num_26p_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrueRou/UsagiPass/e54154e76c6b1fb9b945718e875a2c63708f9814/web/src/assets/rating/num/UI_CMN_Num_26p_1.png -------------------------------------------------------------------------------- /web/src/assets/rating/num/UI_CMN_Num_26p_10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrueRou/UsagiPass/e54154e76c6b1fb9b945718e875a2c63708f9814/web/src/assets/rating/num/UI_CMN_Num_26p_10.png -------------------------------------------------------------------------------- /web/src/assets/rating/num/UI_CMN_Num_26p_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrueRou/UsagiPass/e54154e76c6b1fb9b945718e875a2c63708f9814/web/src/assets/rating/num/UI_CMN_Num_26p_2.png -------------------------------------------------------------------------------- /web/src/assets/rating/num/UI_CMN_Num_26p_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrueRou/UsagiPass/e54154e76c6b1fb9b945718e875a2c63708f9814/web/src/assets/rating/num/UI_CMN_Num_26p_3.png -------------------------------------------------------------------------------- /web/src/assets/rating/num/UI_CMN_Num_26p_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrueRou/UsagiPass/e54154e76c6b1fb9b945718e875a2c63708f9814/web/src/assets/rating/num/UI_CMN_Num_26p_4.png -------------------------------------------------------------------------------- /web/src/assets/rating/num/UI_CMN_Num_26p_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrueRou/UsagiPass/e54154e76c6b1fb9b945718e875a2c63708f9814/web/src/assets/rating/num/UI_CMN_Num_26p_5.png -------------------------------------------------------------------------------- /web/src/assets/rating/num/UI_CMN_Num_26p_6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrueRou/UsagiPass/e54154e76c6b1fb9b945718e875a2c63708f9814/web/src/assets/rating/num/UI_CMN_Num_26p_6.png -------------------------------------------------------------------------------- /web/src/assets/rating/num/UI_CMN_Num_26p_7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrueRou/UsagiPass/e54154e76c6b1fb9b945718e875a2c63708f9814/web/src/assets/rating/num/UI_CMN_Num_26p_7.png -------------------------------------------------------------------------------- /web/src/assets/rating/num/UI_CMN_Num_26p_8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrueRou/UsagiPass/e54154e76c6b1fb9b945718e875a2c63708f9814/web/src/assets/rating/num/UI_CMN_Num_26p_8.png -------------------------------------------------------------------------------- /web/src/assets/rating/num/UI_CMN_Num_26p_9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrueRou/UsagiPass/e54154e76c6b1fb9b945718e875a2c63708f9814/web/src/assets/rating/num/UI_CMN_Num_26p_9.png -------------------------------------------------------------------------------- /web/src/components/CardBack.vue: -------------------------------------------------------------------------------- 1 | 20 | 29 | -------------------------------------------------------------------------------- /web/src/components/CharaInfo.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /web/src/components/DXRating.vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | -------------------------------------------------------------------------------- /web/src/components/PlayerInfo.vue: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /web/src/components/QRCode.vue: -------------------------------------------------------------------------------- 1 | 28 | -------------------------------------------------------------------------------- /web/src/components/menus/Bind.vue: -------------------------------------------------------------------------------- 1 | 46 | 60 | -------------------------------------------------------------------------------- /web/src/components/menus/Gallery.vue: -------------------------------------------------------------------------------- 1 | 53 | -------------------------------------------------------------------------------- /web/src/components/menus/Login.vue: -------------------------------------------------------------------------------- 1 | 33 | 71 | -------------------------------------------------------------------------------- /web/src/components/menus/Update.vue: -------------------------------------------------------------------------------- 1 | 77 | 146 | 159 | -------------------------------------------------------------------------------- /web/src/components/widgets/AnnouncementModal.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 51 | 52 | -------------------------------------------------------------------------------- /web/src/components/widgets/Notification.vue: -------------------------------------------------------------------------------- 1 | 67 | 68 | 109 | 110 | 148 | -------------------------------------------------------------------------------- /web/src/components/widgets/Prompt.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 61 | 62 | -------------------------------------------------------------------------------- /web/src/components/widgets/TermsLink.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 20 | -------------------------------------------------------------------------------- /web/src/main.ts: -------------------------------------------------------------------------------- 1 | import './assets/main.css' 2 | 3 | import { createApp } from 'vue' 4 | import { createPinia } from 'pinia' 5 | 6 | import App from './App.vue' 7 | import router from './router' 8 | import Notification from './components/widgets/Notification.vue' 9 | 10 | const app = createApp(App) 11 | 12 | app.use(createPinia()) 13 | app.use(router) 14 | app.component('Notification', Notification) 15 | 16 | app.mount('#app') 17 | -------------------------------------------------------------------------------- /web/src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from 'vue-router' 2 | import { useUserStore } from '@/stores/user' 3 | import DXPassView from '@/views/DXPassView.vue' 4 | import { useImageStore } from '@/stores/image' 5 | import { useNotificationStore } from '@/stores/notification' 6 | import { Privilege } from '@/types' 7 | 8 | const router = createRouter({ 9 | history: createWebHistory(import.meta.env.BASE_URL), 10 | routes: [ 11 | { 12 | path: '/', 13 | name: 'home', 14 | meta: { requireAuth: true }, 15 | component: DXPassView 16 | }, 17 | { 18 | path: '/cropper/:kind', 19 | name: 'cropper', 20 | props: true, 21 | meta: { requireAuth: true }, 22 | component: () => import('../views/CropperView.vue'), 23 | }, 24 | { 25 | path: '/', 26 | name: 'menus', 27 | component: () => import('../views/MenuView.vue'), 28 | children: [ 29 | { 30 | path: 'login', 31 | name: 'login', 32 | component: () => import('../components/menus/Login.vue'), 33 | }, 34 | { 35 | path: 'preferences/pass', 36 | name: 'preferencesPass', 37 | meta: { requireAuth: true, requireImages: true }, 38 | component: () => import('../components/menus/PreferencesPass.vue'), 39 | }, 40 | { 41 | path: 'update', 42 | name: 'update', 43 | meta: { requireAuth: true }, 44 | component: () => import('../components/menus/Update.vue'), 45 | }, 46 | { 47 | path: 'bind/:server', 48 | name: 'bind', 49 | props: true, 50 | meta: { requireAuth: true }, 51 | component: () => import('../components/menus/Bind.vue'), 52 | }, 53 | { 54 | path: 'gallery/:kind', 55 | name: 'gallery', 56 | props: true, 57 | meta: { requireImages: true }, 58 | component: () => import('../components/menus/Gallery.vue'), 59 | }, 60 | ] 61 | }, 62 | { 63 | path: '/admin', 64 | name: 'admin', 65 | meta: { requiresAuth: true, requiresAdmin: true }, 66 | component: () => import('../views/AdminView.vue'), 67 | }, 68 | { 69 | path: '/:pathMatch(.*)*', 70 | redirect: '/' 71 | }, 72 | ] 73 | }) 74 | 75 | router.beforeEach(async (to, from, next) => { 76 | const userStore = useUserStore() 77 | const imageStore = useImageStore() 78 | const notificationStore = useNotificationStore() 79 | 80 | // Handle query parameters 81 | if (to.query.maid) userStore.maimaiCode = to.query.maid as string 82 | if (to.query.time) userStore.timeLimit = to.query.time as string 83 | 84 | // Load necessary data 85 | if (localStorage.getItem('token') && !userStore.userProfile) await userStore.refreshUser() 86 | if (to.meta.requireImages && !imageStore.images) await imageStore.refreshImages() 87 | 88 | // Check authentication first 89 | if (!userStore.isSignedIn) { 90 | // Handle routes requiring authentication 91 | if (to.meta.requireAuth || to.meta.requiresAdmin) { 92 | notificationStore.error("访问受限", "请先登录") 93 | return next({ name: 'login' }) 94 | } 95 | } else { 96 | // User is signed in 97 | if (to.name === 'login' && from.name !== 'login') { 98 | notificationStore.error("正在跳转", "您已登录, 即将回到上一页面") 99 | return next(from) 100 | } 101 | 102 | // Check admin access 103 | if (to.meta.requiresAdmin && userStore.userProfile?.privilege !== Privilege.ADMIN) { 104 | notificationStore.error("访问受限", "您没有管理员权限") 105 | return next({ name: 'home' }) 106 | } 107 | } 108 | 109 | next() 110 | }) 111 | 112 | export default router 113 | -------------------------------------------------------------------------------- /web/src/stores/announcement.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import { ref } from 'vue'; 3 | import { useUserStore } from './user'; 4 | import { useNotificationStore } from './notification'; 5 | import { markRaw } from 'vue'; 6 | import MarkdownIt from 'markdown-it'; 7 | 8 | // 创建一个markdownit解析器实例 9 | const md = markRaw(new MarkdownIt({ 10 | html: false, // 禁用HTML标签 11 | linkify: true, // 将URL自动转换为链接 12 | typographer: true, // 启用一些语言中立的替换和引号美化 13 | breaks: true, // 转换换行符为
14 | })); 15 | 16 | export interface Announcement { 17 | id: number; 18 | title: string; 19 | content: string; 20 | is_active: boolean; 21 | created_at: string; 22 | updated_at: string; 23 | is_read: boolean; 24 | } 25 | 26 | export const useAnnouncementStore = defineStore('announcement', () => { 27 | const userStore = useUserStore(); 28 | const notificationStore = useNotificationStore(); 29 | 30 | const announcements = ref([]); 31 | const unreadAnnouncements = ref([]); 32 | const currentAnnouncement = ref(null); 33 | const showAnnouncementModal = ref(false); 34 | 35 | const fetchAnnouncements = async () => { 36 | try { 37 | const response = await userStore.axiosInstance.get('/announcements'); 38 | announcements.value = response.data; 39 | updateUnreadAnnouncements(); 40 | return announcements.value; 41 | } catch (error: any) { 42 | notificationStore.error('获取公告失败', error.response?.data?.detail || '未知错误'); 43 | return []; 44 | } 45 | }; 46 | 47 | const fetchAnnouncement = async (id: number) => { 48 | try { 49 | const response = await userStore.axiosInstance.get(`/announcements/${id}`); 50 | const announcement = response.data; 51 | return announcement; 52 | } catch (error: any) { 53 | notificationStore.error('获取公告失败', error.response?.data?.detail || '未知错误'); 54 | return null; 55 | } 56 | }; 57 | 58 | const createAnnouncement = async (title: string, content: string, isActive = true) => { 59 | try { 60 | const response = await userStore.axiosInstance.post('/announcements', { 61 | title, 62 | content, 63 | is_active: isActive 64 | }); 65 | await fetchAnnouncements(); 66 | return response.data; 67 | } catch (error: any) { 68 | notificationStore.error('创建公告失败', error.response?.data?.detail || '未知错误'); 69 | return null; 70 | } 71 | }; 72 | 73 | const updateAnnouncement = async (id: number, data: Partial) => { 74 | try { 75 | const response = await userStore.axiosInstance.patch(`/announcements/${id}`, data); 76 | await fetchAnnouncements(); 77 | return response.data; 78 | } catch (error: any) { 79 | notificationStore.error('更新公告失败', error.response?.data?.detail || '未知错误'); 80 | return null; 81 | } 82 | }; 83 | 84 | const deleteAnnouncement = async (id: number) => { 85 | try { 86 | await userStore.axiosInstance.delete(`/announcements/${id}`); 87 | await fetchAnnouncements(); 88 | return true; 89 | } catch (error: any) { 90 | notificationStore.error('删除公告失败', error.response?.data?.detail || '未知错误'); 91 | return false; 92 | } 93 | }; 94 | 95 | const markAsRead = async (id: number) => { 96 | try { 97 | await userStore.axiosInstance.post(`/announcements/${id}/read`); 98 | const index = announcements.value.findIndex(a => a.id === id); 99 | if (index !== -1) { 100 | announcements.value[index].is_read = true; 101 | } 102 | updateUnreadAnnouncements(); 103 | return true; 104 | } catch (error: any) { 105 | notificationStore.error('标记公告已读失败', error.response?.data?.detail || '未知错误'); 106 | return false; 107 | } 108 | }; 109 | 110 | const updateUnreadAnnouncements = () => { 111 | unreadAnnouncements.value = announcements.value.filter(a => !a.is_read && a.is_active); 112 | }; 113 | 114 | const showNextUnreadAnnouncement = () => { 115 | if (unreadAnnouncements.value.length > 0) { 116 | currentAnnouncement.value = unreadAnnouncements.value[0]; 117 | showAnnouncementModal.value = true; 118 | return true; 119 | } 120 | return false; 121 | }; 122 | 123 | const handleCurrentAnnouncementRead = async () => { 124 | if (currentAnnouncement.value) { 125 | await markAsRead(currentAnnouncement.value.id); 126 | showAnnouncementModal.value = false; 127 | return showNextUnreadAnnouncement(); 128 | } 129 | return false; 130 | }; 131 | 132 | const renderMarkdown = (content: string) => { 133 | return md.render(content); 134 | }; 135 | 136 | const initAnnouncements = async () => { 137 | if (userStore.isSignedIn) { 138 | await fetchAnnouncements(); 139 | showNextUnreadAnnouncement(); 140 | } 141 | }; 142 | 143 | return { 144 | announcements, 145 | unreadAnnouncements, 146 | currentAnnouncement, 147 | showAnnouncementModal, 148 | fetchAnnouncements, 149 | fetchAnnouncement, 150 | createAnnouncement, 151 | updateAnnouncement, 152 | deleteAnnouncement, 153 | markAsRead, 154 | showNextUnreadAnnouncement, 155 | handleCurrentAnnouncementRead, 156 | renderMarkdown, 157 | initAnnouncements 158 | }; 159 | }); -------------------------------------------------------------------------------- /web/src/stores/image.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia" 2 | import { ref } from "vue"; 3 | import { useUserStore } from "./user"; 4 | import { useNotificationStore } from "@/stores/notification"; 5 | import router from "@/router"; 6 | import type { Image, Kind, Preference } from "@/types"; 7 | 8 | export const useImageStore = defineStore('image', () => { 9 | const userStore = useUserStore(); 10 | const notificationStore = useNotificationStore(); 11 | const images = ref>() 12 | const wanderingPreferences = ref() 13 | 14 | async function refreshImages() { 15 | try { 16 | const response = await userStore.axiosInstance.get('/bits') 17 | images.value = response.data.reduce((acc: Record, item: Image) => { 18 | if (!acc[item.kind]) { 19 | acc[item.kind] = []; 20 | } 21 | acc[item.kind].push(item); 22 | return acc; 23 | }, {}); 24 | } catch (error: any) { 25 | notificationStore.error("获取失败", error.response.data.detail); 26 | } 27 | } 28 | 29 | async function uploadImage(name: string, kind: Kind, file: Blob) { 30 | try { 31 | const formData = new FormData(); 32 | formData.append('file', file); 33 | await userStore.axiosInstance.post(`/images?name=${name}&kind=${kind}`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }); 34 | await refreshImages(); 35 | router.back(); 36 | } catch (error: any) { 37 | notificationStore.error("上传失败", error.response.data.detail); 38 | } 39 | } 40 | 41 | async function deleteImage(image: Image) { 42 | try { 43 | if (confirm(`确定删除 ${image.name} 吗?`)) { 44 | await userStore.axiosInstance.delete("/images/" + image.id) 45 | await refreshImages(); 46 | } 47 | } catch (error: any) { 48 | notificationStore.error("删除失败", error.response.data.detail); 49 | } 50 | } 51 | 52 | async function patchImage(image: Image) { 53 | try { 54 | await userStore.axiosInstance.patch("/images/" + image.id + "?name=" + image.name); 55 | await refreshImages(); 56 | } catch (error: any) { 57 | notificationStore.error("更新失败", error.response.data.detail); 58 | } 59 | } 60 | 61 | return { images, wanderingPreferences, refreshImages, uploadImage, deleteImage, patchImage } 62 | }) -------------------------------------------------------------------------------- /web/src/stores/notification.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { ref } from 'vue' 3 | 4 | export type NotificationType = 'info' | 'success' | 'warning' | 'error' 5 | 6 | export interface Notification { 7 | id: number 8 | type: NotificationType 9 | title: string 10 | message: string 11 | timeout?: number 12 | isPersistent?: boolean 13 | } 14 | 15 | export const useNotificationStore = defineStore('notification', () => { 16 | const notifications = ref([]) 17 | let nextId = 1 18 | 19 | function add(notification: Omit) { 20 | const id = nextId++ 21 | const newNotification = { 22 | id, 23 | ...notification, 24 | timeout: notification.timeout || (notification.isPersistent ? 0 : 5000) 25 | } 26 | notifications.value.push(newNotification) 27 | 28 | // 如果不是持久通知,则设置自动移除 29 | if (!notification.isPersistent && notification.timeout !== 0) { 30 | setTimeout(() => { 31 | remove(id) 32 | }, notification.timeout || 5000) 33 | } 34 | 35 | return id 36 | } 37 | 38 | function remove(id: number) { 39 | const index = notifications.value.findIndex(n => n.id === id) 40 | if (index !== -1) { 41 | notifications.value.splice(index, 1) 42 | } 43 | } 44 | 45 | function info(title: string, message: string, options = {}) { 46 | return add({ 47 | type: 'info', 48 | title, 49 | message, 50 | ...options 51 | }) 52 | } 53 | 54 | function success(title: string, message: string, options = {}) { 55 | return add({ 56 | type: 'success', 57 | title, 58 | message, 59 | ...options 60 | }) 61 | } 62 | 63 | function warning(title: string, message: string, options = {}) { 64 | return add({ 65 | type: 'warning', 66 | title, 67 | message, 68 | ...options 69 | }) 70 | } 71 | 72 | function error(title: string, message: string, options = {}) { 73 | return add({ 74 | type: 'error', 75 | title, 76 | message, 77 | ...options 78 | }) 79 | } 80 | 81 | function clearAll() { 82 | notifications.value = [] 83 | } 84 | 85 | return { 86 | notifications, 87 | add, 88 | remove, 89 | info, 90 | success, 91 | warning, 92 | error, 93 | clearAll 94 | } 95 | }) 96 | -------------------------------------------------------------------------------- /web/src/stores/server.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { defineStore } from "pinia" 3 | import { ref } from "vue"; 4 | import { useNotificationStore } from "./notification"; 5 | 6 | export const useServerStore = defineStore('server', () => { 7 | const maimaiVersion = ref("[maimaiDX]EX1.50-D") 8 | const serverNames: Record = { 1: "水鱼", 2: "落雪", 3: "微信" } 9 | const serverKinds = ref> | null>(null) 10 | const notificationStore = useNotificationStore(); 11 | 12 | const axiosInstance = ref(axios.create({ 13 | baseURL: import.meta.env.VITE_URL, 14 | timeout: 10000, 15 | })); 16 | 17 | async function refreshKind() { 18 | try { 19 | const response = (await axiosInstance.value.get('/kinds')) 20 | serverKinds.value = response.data 21 | } catch (error) { 22 | notificationStore.error("服务器错误", "无法刷新图片类型信息,请联系开发者"); 23 | console.error(error) 24 | } 25 | } 26 | 27 | return { axiosInstance, serverKinds, maimaiVersion, serverNames, refreshKind } 28 | }) -------------------------------------------------------------------------------- /web/src/stores/user.ts: -------------------------------------------------------------------------------- 1 | import router from "@/router" 2 | import axios from "axios" 3 | import { defineStore } from "pinia" 4 | import { computed, ref, type Ref } from "vue" 5 | import { type Router } from "vue-router" 6 | import { useImageStore } from "./image" 7 | import { useNotificationStore } from "./notification" 8 | import { AccountServer, type Kind, type UserProfile } from "@/types" 9 | 10 | export const useUserStore = defineStore('user', () => { 11 | const imageStore = useImageStore(); 12 | const notificationStore = useNotificationStore(); 13 | 14 | const token = ref(localStorage.getItem('token')); 15 | const maimaiCode = ref(""); 16 | const timeLimit = ref("12:00:00"); 17 | const isSignedIn = ref(false); 18 | const userProfile = ref(null); 19 | const cropperImage = ref(null); 20 | 21 | const simplifiedCode = computed(() => maimaiCode.value.slice(8, 28).match(/.{1,4}/g)?.join(' ')); 22 | const axiosInstance = computed(() => axios.create({ 23 | baseURL: import.meta.env.VITE_URL, 24 | timeout: 10000, 25 | headers: { 'Authorization': `Bearer ${token.value}` }, 26 | })); 27 | const preferAccount = computed(() => userProfile.value?.accounts[userProfile.value.prefer_server]); 28 | 29 | async function login(target: string, username: string, password: string) { 30 | try { 31 | const data = await axiosInstance.value.post(`/accounts/token/${target}`, new URLSearchParams({ 32 | username, 33 | password 34 | })); 35 | localStorage.setItem('token', data.data.access_token); 36 | token.value = data.data.access_token; 37 | await refreshUser(); 38 | // 如果之前获取过图片列表,刷新列表 39 | if (imageStore.images) await imageStore.refreshImages(); 40 | notificationStore.success("登录成功", `欢迎回来,${preferAccount.value?.nickname}`); 41 | return true; 42 | } catch (error: any) { 43 | notificationStore.error("登录失败", error.response?.data?.detail || "未知错误"); 44 | return false; 45 | } 46 | } 47 | 48 | function logout(refresh = true) { 49 | localStorage.removeItem('token'); 50 | token.value = null; 51 | isSignedIn.value = false; 52 | userProfile.value = null; 53 | if (refresh) router.go(0); 54 | } 55 | 56 | async function bind(server: AccountServer, username: string, password: string) { 57 | try { 58 | await axiosInstance.value.post(`/accounts/bind/${AccountServer[server].toLowerCase()}`, new URLSearchParams({ username, password })); 59 | await refreshUser(); 60 | notificationStore.success("绑定成功", `查分器账户绑定成功`); 61 | router.back(); 62 | } catch (error: any) { 63 | notificationStore.error("绑定失败", error.response?.data?.detail || "未知错误"); 64 | } 65 | } 66 | 67 | async function refreshUser() { 68 | try { 69 | const response = (await axiosInstance.value.get('/users/profile')); 70 | userProfile.value = response.data; 71 | isSignedIn.value = true; 72 | } catch (error) { 73 | isSignedIn.value = false; 74 | userProfile.value = null; 75 | } 76 | } 77 | 78 | async function patchPreferences() { 79 | try { 80 | await axiosInstance.value.patch('/users/preference', userProfile.value!.preferences); 81 | await refreshUser(); 82 | notificationStore.success("保存成功", "个人偏好设置已保存"); 83 | router.back(); 84 | } catch (error: any) { 85 | notificationStore.error("保存失败", error.response?.data?.detail || "未知错误"); 86 | } 87 | } 88 | 89 | async function patchPreferServer(prefer_server: number) { 90 | try { 91 | await axiosInstance.value.patch('/users', { prefer_server: prefer_server }); 92 | await refreshUser(); 93 | notificationStore.success("设置成功", `已将${prefer_server === 1 ? '水鱼' : '落雪'}设为优先数据源`); 94 | } catch (error: any) { 95 | notificationStore.error("设置失败", error.response?.data?.detail || "未知错误"); 96 | } 97 | } 98 | 99 | const updateProber = async () => { 100 | if (navigator.userAgent.toLowerCase().indexOf('micromessenger') == -1) { 101 | notificationStore.error("更新失败", "无法获取玩家信息,请在微信环境中更新查分器"); 102 | return; 103 | } 104 | try { 105 | const testWahlap = await axios.get("http://tgk-wcaime.wahlap.com/test"); 106 | // 502是历史遗留规则中的一个错误码,代表代理配置过时 107 | if (testWahlap.status == 502) { 108 | notificationStore.warning("代理配置过时", "当前代理配置已过时,请更新订阅后重试"); 109 | return; 110 | } 111 | } catch (error: any) { } 112 | try { 113 | const resp = await axiosInstance.value.post("/accounts/update/oauth"); 114 | window.location.href = resp.data.url; 115 | } catch (error: any) { 116 | notificationStore.error("更新失败", error.response?.data?.detail || "未知错误"); 117 | } 118 | } 119 | 120 | const changeImagePicker = (imagePicker: HTMLInputElement, kind: Kind, cropperImage: Ref, router: Router) => { 121 | const file = imagePicker.files?.[0]; 122 | if (file && kind) { 123 | const reader = new FileReader(); 124 | reader.onload = function (ev) { 125 | cropperImage.value = ev.target?.result as string; 126 | router.push({ name: 'cropper', params: { kind: kind } }); 127 | } 128 | reader.readAsDataURL(file); 129 | } 130 | } 131 | 132 | const openImagePicker = (kind: Kind, imagePicker: HTMLInputElement) => { 133 | if (imagePicker) { 134 | imagePicker.onchange = () => changeImagePicker(imagePicker, kind, cropperImage, router); 135 | imagePicker.click(); 136 | } 137 | } 138 | 139 | return { 140 | axiosInstance, 141 | token, 142 | isSignedIn, 143 | userProfile, 144 | cropperImage, 145 | maimaiCode, 146 | timeLimit, 147 | simplifiedCode, 148 | preferAccount, 149 | login, 150 | logout, 151 | bind, 152 | refreshUser, 153 | patchPreferences, 154 | patchPreferServer, 155 | updateProber, 156 | openImagePicker 157 | } 158 | }) -------------------------------------------------------------------------------- /web/src/types.ts: -------------------------------------------------------------------------------- 1 | type Kind = "background" | "frame" | "character" | "passname" | "mask" 2 | 3 | enum Privilege { 4 | BANNED = 1, 5 | NORMAL = 2, 6 | ADMIN = 3 7 | } 8 | 9 | enum AccountServer { 10 | DIVINGFISH = 1, 11 | LXNS = 2, 12 | WECHAT = 3 13 | } 14 | 15 | interface Image { 16 | id: string; 17 | name: string; 18 | kind: Kind; 19 | uploaded_by?: string; 20 | } 21 | 22 | interface Preference { 23 | character: Image; 24 | background: Image; 25 | frame: Image; 26 | passname: Image; 27 | maimai_version?: string; 28 | simplified_code?: string; 29 | character_name?: string; 30 | friend_code?: string; 31 | display_name?: string; 32 | dx_rating?: string; 33 | qr_size?: number; 34 | mask_type?: number; 35 | chara_info_color: string; 36 | } 37 | 38 | interface UserAccount { 39 | account_name: string; 40 | nickname: string; 41 | player_rating: number; 42 | } 43 | 44 | interface UserProfile { 45 | username: string; 46 | api_token: string; 47 | prefer_server: AccountServer; 48 | privilege: Privilege; 49 | preferences: Preference; 50 | accounts: Record; 51 | } 52 | 53 | interface CrawlerResult { 54 | account_server: number; 55 | success: boolean; 56 | scores_num: number; 57 | from_rating: number; 58 | to_rating: number; 59 | err_msg: string; 60 | elapsed_time: number; 61 | } 62 | 63 | 64 | export { Privilege, AccountServer }; 65 | export type { Kind, Preference, UserAccount, UserProfile, CrawlerResult, Image }; 66 | -------------------------------------------------------------------------------- /web/src/views/AdminView.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 35 | -------------------------------------------------------------------------------- /web/src/views/CropperView.vue: -------------------------------------------------------------------------------- 1 | 43 | 59 | -------------------------------------------------------------------------------- /web/src/views/DXBaseView.vue: -------------------------------------------------------------------------------- 1 | 30 | 67 | -------------------------------------------------------------------------------- /web/src/views/DXPassView.vue: -------------------------------------------------------------------------------- 1 | 20 | 24 | -------------------------------------------------------------------------------- /web/src/views/MenuView.vue: -------------------------------------------------------------------------------- 1 | 15 | -------------------------------------------------------------------------------- /web/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | "./index.html", 5 | "./src/**/*.{vue,js,ts,jsx,tsx}", 6 | ], 7 | theme: { 8 | extend: {}, 9 | }, 10 | plugins: [], 11 | } 12 | 13 | -------------------------------------------------------------------------------- /web/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.dom.json", 3 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], 4 | "exclude": ["src/**/__tests__/*"], 5 | "compilerOptions": { 6 | "composite": true, 7 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 8 | 9 | "baseUrl": ".", 10 | "paths": { 11 | "@/*": ["./src/*"] 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.node.json" 6 | }, 7 | { 8 | "path": "./tsconfig.app.json" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /web/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node20/tsconfig.json", 3 | "include": [ 4 | "vite.config.*", 5 | "vitest.config.*", 6 | "cypress.config.*", 7 | "nightwatch.conf.*", 8 | "playwright.config.*" 9 | ], 10 | "compilerOptions": { 11 | "composite": true, 12 | "noEmit": true, 13 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 14 | 15 | "module": "ESNext", 16 | "moduleResolution": "Bundler", 17 | "types": ["node"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url' 2 | 3 | import { defineConfig } from 'vite' 4 | import vue from '@vitejs/plugin-vue' 5 | import vueJsx from '@vitejs/plugin-vue-jsx' 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | envDir: "../", 10 | plugins: [ 11 | vue(), 12 | vueJsx(), 13 | ], 14 | resolve: { 15 | alias: { 16 | '@': fileURLToPath(new URL('./src', import.meta.url)) 17 | } 18 | } 19 | }) 20 | --------------------------------------------------------------------------------