├── .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 |

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 | 
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 |
22 |
23 |
24 |
25 |
26 |
27 |
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 |
--------------------------------------------------------------------------------
/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 |
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 |
21 |
22 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/web/src/components/CharaInfo.vue:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
{{ props.chara }}
14 |
15 |
17 |
18 | ブ一スト期限
19 | {{ props.time }}
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/web/src/components/DXRating.vue:
--------------------------------------------------------------------------------
1 |
70 |
71 |
72 |
73 |
74 |
75 |
--------------------------------------------------------------------------------
/web/src/components/PlayerInfo.vue:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
12 |
{{ props.username }}
13 |

14 |
15 |
16 |
20 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/web/src/components/QRCode.vue:
--------------------------------------------------------------------------------
1 |
28 |
29 |
30 |
![]()
31 |
32 |
--------------------------------------------------------------------------------
/web/src/components/menus/Bind.vue:
--------------------------------------------------------------------------------
1 |
46 |
47 |
48 |
{{ serverLang[props.server].title }}
49 | {{ serverLang[props.server].subtitle }}
50 | {{ serverLang[props.server].subtitle2 }}
51 |
52 |
53 |
55 |
57 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/web/src/components/menus/Gallery.vue:
--------------------------------------------------------------------------------
1 |
53 |
54 |
56 |
57 |
58 |
59 |
{{ '选取' + kindDict[props.kind] }}
60 |
61 |
62 |
63 |
64 |
69 |
71 | {{ image.name }}
72 |
73 |
![]()
74 |
79 |
84 |
85 |
86 |
87 |
88 |
--------------------------------------------------------------------------------
/web/src/components/menus/Login.vue:
--------------------------------------------------------------------------------
1 |
33 |
34 |
35 |
登录UsagiPass
36 |
使用水鱼或落雪账户登录
落雪登录使用 个人 API 密钥 作为密码
37 |
38 |
39 |
40 |
54 |
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/web/src/components/menus/Update.vue:
--------------------------------------------------------------------------------
1 |
77 |
78 | 更新查分器
79 | 将当前微信登录玩家的游戏数据同步至水鱼 / 落雪查分器
80 |
82 |
83 |
更新状态
84 |
85 |
86 |
{{ statusText }}
87 |
88 |
90 |
91 |
92 | {{ Math.floor(progress) }}%
93 |
94 |
95 |
96 |
97 | 获取数据
98 | 上传数据
99 | 完成
100 |
101 |
102 |
103 |
104 |
105 |
106 |
成绩获取结果
107 | {{ serverStore.serverNames[result.account_server] }}上传结果
108 |
109 |
110 |
111 |
112 |
113 | 获取 {{ result.scores_num }} 个成绩 {{ result.success ? '成功' : '失败' }}
114 |
115 |
116 | 用时: {{ result.elapsed_time.toFixed(2) }} 秒
117 |
118 |
119 | 失败原因: {{ result.err_msg }}
120 |
121 |
122 |
123 |
124 | 更新 {{ result.scores_num }} 个成绩 {{ result.success ? '成功' : '失败' }}
125 |
126 |
127 | 用时: {{ result.elapsed_time.toFixed(2) }} 秒
128 | Rating 变化: {{ result.from_rating }} -> {{ result.to_rating }}
129 |
130 |
131 | 失败原因: {{ result.err_msg }}
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
141 | 回到首页
142 |
143 |
144 |
145 |
146 |
159 |
--------------------------------------------------------------------------------
/web/src/components/widgets/AnnouncementModal.vue:
--------------------------------------------------------------------------------
1 |
18 |
19 |
20 |
21 |
23 |
24 |
25 |
26 |
{{ announcementStore.currentAnnouncement.title }}
27 |
33 |
34 |
35 |
36 |
39 |
40 |
41 |
42 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
--------------------------------------------------------------------------------
/web/src/components/widgets/Notification.vue:
--------------------------------------------------------------------------------
1 |
67 |
68 |
69 |
71 |
72 |
73 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
{{ notification.title }}
87 |
{{
88 | notification.message }}
89 |
90 |
91 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
148 |
--------------------------------------------------------------------------------
/web/src/components/widgets/Prompt.vue:
--------------------------------------------------------------------------------
1 |
34 |
35 |
36 |
37 |
59 |
60 |
61 |
62 |
--------------------------------------------------------------------------------
/web/src/components/widgets/TermsLink.vue:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
14 | {{ prefix }}
15 |
用户条款
16 | 和
17 |
隐私政策
18 |
19 |
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 |
15 |
16 |
17 |
18 |
24 |
25 |
26 |
27 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/web/src/views/CropperView.vue:
--------------------------------------------------------------------------------
1 |
43 |
44 |
46 |
47 |
48 |
51 |
52 |
53 |
58 |
59 |
--------------------------------------------------------------------------------
/web/src/views/DXBaseView.vue:
--------------------------------------------------------------------------------
1 |
30 |
31 |
32 |
33 |
34 |
35 |
39 |
43 |
44 |
45 |
47 |
48 |
49 |
50 |
51 |
52 |
56 |
57 |
58 |
59 |

60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
--------------------------------------------------------------------------------
/web/src/views/DXPassView.vue:
--------------------------------------------------------------------------------
1 |
20 |
21 |
23 |
24 |
--------------------------------------------------------------------------------
/web/src/views/MenuView.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
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 |
--------------------------------------------------------------------------------