├── .dockerignore
├── .env.example
├── .gitignore
├── Dockerfile
├── LICENSE
├── README.md
├── activate_tool
├── README.md
├── activate.py
└── activate_config.json.example
├── docker-compose.yml
├── frontend
├── .gitignore
├── README.md
├── eslint.config.js
├── index.html
├── package.json
├── pnpm-lock.yaml
├── public
│ └── vite.svg
├── src
│ ├── App.css
│ ├── App.tsx
│ ├── assets
│ │ └── react.svg
│ ├── components
│ │ ├── Header
│ │ │ ├── index.css
│ │ │ └── index.tsx
│ │ ├── SessionInitializer.tsx
│ │ └── SessionManager.tsx
│ ├── index.css
│ ├── main.tsx
│ ├── pages
│ │ ├── AccountManager
│ │ │ └── index.tsx
│ │ ├── Activate
│ │ │ ├── index.css
│ │ │ └── index.tsx
│ │ ├── History
│ │ │ ├── index.css
│ │ │ └── index.tsx
│ │ ├── ProxyPool
│ │ │ ├── index.css
│ │ │ └── index.tsx
│ │ └── Register
│ │ │ ├── index.css
│ │ │ └── index.tsx
│ ├── services
│ │ └── api.ts
│ ├── utils
│ │ ├── config.ts
│ │ └── httpClient.ts
│ └── vite-env.d.ts
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
├── requirements.txt
├── run.py
└── utils
├── __pycache__
├── database.cpython-311.pyc
├── email_client.cpython-311.pyc
├── pikpak.cpython-311.pyc
├── pk_email.cpython-311.pyc
└── session_manager.cpython-311.pyc
├── database.py
├── email_client.py
├── pikpak.py
├── pk_email.py
└── session_manager.py
/.dockerignore:
--------------------------------------------------------------------------------
1 | __pycache__
2 | *.pyc
3 | .git
4 | .venv
5 | tests
6 |
7 | # Git repository files
8 | .git/
9 | .gitignore
10 |
11 | # Node modules
12 | node_modules/
13 | frontend/node_modules/
14 |
15 | # Environment files (Ensure sensitive data isn't accidentally included)
16 | .env
17 | frontend/.env
18 |
19 | # Build artifacts (Frontend build happens inside container, but good practice)
20 | dist/
21 | build/
22 | frontend/dist/
23 | frontend/build/
24 |
25 | # Logs
26 | logs/
27 | *.log
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 | pnpm-debug.log*
32 |
33 | # OS generated files
34 | .DS_Store
35 | Thumbs.db
36 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/1653756334/PikPakInvitation/6e76029c8c940d56901986ca18ab74e12229d668/.env.example
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .env
2 | account/*
3 | account copy/
4 | final_review_gate.py
5 | accounts.db
6 | activate_tool/*.log
7 | activate_tool/activate_config.json
8 | .cursor
9 | apis.js
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:lts-alpine AS frontend-builder
2 |
3 | WORKDIR /app/frontend
4 |
5 | RUN npm install -g pnpm
6 |
7 | COPY frontend/package.json frontend/pnpm-lock.yaml ./
8 |
9 | RUN pnpm install --frozen-lockfile
10 |
11 | COPY frontend/ ./
12 |
13 | RUN pnpm build
14 |
15 | FROM python:3.10-slim AS final
16 |
17 | WORKDIR /app
18 |
19 | COPY requirements.txt ./
20 | RUN pip install --no-cache-dir --upgrade pip && \
21 | pip install --no-cache-dir -r requirements.txt
22 |
23 | RUN mkdir account
24 |
25 | RUN mkdir -p templates static
26 |
27 | # Create empty database file to ensure proper mounting
28 | RUN touch accounts.db
29 |
30 | COPY run.py ./
31 | COPY utils/ ./utils/
32 |
33 | COPY --from=frontend-builder /app/frontend/dist/index.html ./templates/
34 | COPY --from=frontend-builder /app/frontend/dist/* ./static/
35 |
36 | EXPOSE 5000
37 |
38 | CMD ["python", "run.py"]
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # PikPak 自动邀请
2 |
3 | 一个帮助管理PikPak邀请的工具,包含前端界面和后端服务。
4 |
5 | **理论上输入账号后,一下都不用点,等着把列表里面账号注册完成就行**
6 |
7 | ## 预览
8 | [点击在线使用](https://pikpak.dddai.de/)
9 |
10 | ## 项目结构
11 |
12 | - `frontend/`: 前端代码,使用 pnpm 管理依赖
13 | - 后端: Python 实现的服务
14 |
15 | ## 环境变量
16 | (可选) MAIL_POINT_API_URL 使用:https://github.com/HChaoHui/msOauth2api 部署后获得
17 |
18 | ADMIN_SESSION_ID: 管理员密码 (记得修改)
19 |
20 | 如果不提供此环境变量,需要(邮箱,密码)支持imap登录
21 |
22 | ## 部署方式
23 |
24 | ### 前端部署
25 |
26 | ```bash
27 | # 进入前端目录
28 | cd frontend
29 |
30 | # 安装依赖
31 | pnpm install
32 |
33 | # 开发模式运行
34 | pnpm dev
35 |
36 | # 构建生产版本
37 | pnpm build
38 | ```
39 |
40 | ### 后端部署
41 |
42 | #### 1. 环境变量
43 | 复制 .env.example 到 .env
44 |
45 | 修改环境变量的值
46 |
47 | ```bash
48 | MAIL_POINT_API_URL=https://your-endpoint.com
49 | ADMIN_SESSION_ID=your_admin_session_id
50 | ```
51 |
52 | #### 2. 源码运行
53 |
54 | ```bash
55 | # 安装依赖
56 | pip install -r requirements.txt
57 |
58 | # 运行应用
59 | python run.py
60 | ```
61 |
62 | ### Docker 部署
63 |
64 | 项目提供了 Dockerfile,可以一键构建包含前后端的完整应用。
65 |
66 | #### 运行 Docker 容器
67 |
68 | ```bash
69 | # 创建并运行容器
70 | docker run -d \
71 | --name pikpak-auto \
72 | -p 5000:5000 \
73 | -e MAIL_POINT_API_URL=https://your-endpoint.com \
74 | -e ADMIN_SESSION_ID=your_admin_session_id \
75 | -v $(pwd)/account:/app/account \
76 | vichus/pikpak-invitation:latest
77 | ```
78 |
79 | 参数说明:
80 | - `-d`: 后台运行容器
81 | - `-p 5000:5000`: 将容器内的 5000 端口映射到主机的 5000 端口
82 | - `-e MAIL_POINT_API_URL=...`: 设置环境变量
83 | - `-v $(pwd)/account:/app/account`: 将本地 account 目录挂载到容器内,保存账号数据
84 |
85 | #### 4. 查看容器日志
86 |
87 | ```bash
88 | docker logs -f pikpak-auto
89 | ```
90 |
91 | #### 5. 停止和重启容器
92 |
93 | ```bash
94 | # 停止容器
95 | docker stop pikpak-auto
96 |
97 | # 重启容器
98 | docker start pikpak-auto
99 | ```
100 |
101 | ### Docker Compose 部署
102 |
103 | 如果你更喜欢使用 Docker Compose 进行部署,请按照以下步骤操作:
104 |
105 | #### 1. 启动服务
106 |
107 | 启动前记得修改 `docker-compose.yml` 的环境变量
108 |
109 | ```bash
110 | # 在项目根目录下启动服务
111 | docker-compose up -d
112 | ```
113 |
114 | #### 2. 查看日志
115 |
116 | ```bash
117 | # 查看服务日志
118 | docker-compose logs -f
119 | ```
120 |
121 | #### 3. 停止和重启服务
122 |
123 | ```bash
124 | # 停止服务
125 | docker-compose down
126 |
127 | # 重启服务
128 | docker-compose up -d
129 | ```
130 |
131 | 鸣谢:
132 |
133 | [Pikpak-Auto-Invitation](https://github.com/Bear-biscuit/Pikpak-Auto-Invitation)
134 |
135 | [纸鸢地址发布页](https://kiteyuan.info/)
136 |
137 | [msOauth2api](https://github.com/HChaoHui/msOauth2api)
138 |
--------------------------------------------------------------------------------
/activate_tool/README.md:
--------------------------------------------------------------------------------
1 | # PikPak 自动激活脚本使用说明
2 |
3 | ## 功能简介
4 |
5 | `activate.py` 是一个独立的自动激活脚本,用于:
6 | - 查询上次激活时间为前一天12点后且激活次数小于3的账号
7 | - 自动调用激活接口进行激活
8 | - 支持重试机制和日志记录
9 | - 每个账号激活后暂停10-30秒
10 |
11 | ## 配置文件
12 |
13 | 首先复制配置文件模板:
14 | ```bash
15 | cp activate_config.json.example activate_config.json
16 | ```
17 |
18 | 然后编辑 `activate_config.json`:
19 | ```json
20 | {
21 | "activation_key": "your_actual_activation_key",
22 | "api_base_url": "http://localhost:5000",
23 | "session_id": "auto_activator",
24 | "db_path": "accounts.db",
25 | "min_sleep_seconds": 10,
26 | "max_sleep_seconds": 30,
27 | "max_retries": 3,
28 | "retry_delay_seconds": 5,
29 | "max_activation_count": 3
30 | }
31 | ```
32 |
33 | ### 配置说明
34 |
35 | - `activation_key`: 激活密钥(必需), 在 https://kiteyuan.info/ 获取
36 | - `api_base_url`: API服务地址,默认本地5000端口
37 | - `session_id`: 会话ID,用于数据库操作权限。管理员会话可激活所有账号,普通会话只能激活自己的账号
38 | - `db_path`: 数据库文件路径
39 | - `min_sleep_seconds`: 每个账号激活后的最小暂停时间
40 | - `max_sleep_seconds`: 每个账号激活后的最大暂停时间
41 | - `max_retries`: 激活失败时的最大重试次数
42 | - `retry_delay_seconds`: 重试间隔时间
43 | - `max_activation_count`: 最大激活次数限制,默认为3
44 |
45 | ## 使用方法
46 |
47 | ### 1. 使用配置文件
48 |
49 | ```bash
50 | python activate.py
51 | ```
52 |
53 | ### 2. 使用命令行参数
54 |
55 | ```bash
56 | python activate.py --key "your_activation_key" --max-activations 3
57 | ```
58 |
59 | ### 3. 指定配置文件
60 |
61 | ```bash
62 | python activate.py --config custom_config.json
63 | ```
64 |
65 | ### 命令行参数
66 |
67 | - `--key`, `-k`: 激活密钥
68 | - `--db`, `-d`: 数据库文件路径
69 | - `--url`, `-u`: API服务地址
70 | - `--session`, `-s`: 会话ID
71 | - `--config`, `-c`: 配置文件路径
72 | - `--max-activations`, `-m`: 最大激活次数限制
73 |
74 | ## 权限控制
75 |
76 | ### 管理员会话
77 | - 可以激活所有用户的符合条件账号
78 | - 日志中会显示每个账号的会话ID信息
79 |
80 | ### 普通用户会话
81 | - 使用普通的会话ID(不包含管理员关键字)
82 | - 只能激活自己会话ID下的账号
83 | - 提供了数据隔离和安全保护
84 |
85 | ## 运行逻辑
86 |
87 | 1. **权限检查**: 根据会话ID判断是管理员还是普通用户
88 | 2. **查询账号**: 查找上次激活时间为前一天12点后且激活次数小于3的账号
89 | 3. **逐个激活**: 不使用并发,按顺序激活每个账号
90 | 4. **暂停等待**: 每个账号激活后暂停10-30秒
91 | 5. **重试机制**: 激活失败时自动重试,最多3次
92 | 6. **日志记录**: 详细记录激活过程和统计信息
93 |
94 | ## 日志文件
95 |
96 | - `activate.log`: 详细的激活日志
97 | - `activation_stats.log`: 激活统计信息
98 |
99 | ## 定时运行
100 |
101 | ### 使用cron(Linux/Mac)
102 |
103 | 编辑crontab:
104 | ```bash
105 | crontab -e
106 | ```
107 |
108 | 添加定时任务(每天早上8点运行):
109 | ```bash
110 | 0 8 * * * cd /path/to/your/project && python activate.py
111 | ```
112 |
113 | ### 使用Windows任务计划程序
114 |
115 | 1. 打开"任务计划程序"
116 | 2. 创建基本任务
117 | 3. 设置触发器(例如每日)
118 | 4. 设置操作:启动程序
119 | - 程序:`python`
120 | - 参数:`activate.py`
121 | - 起始于:脚本所在目录
122 |
123 | ## 注意事项
124 |
125 | 1. 确保 `run.py` 服务正在运行
126 | 2. 激活密钥需要有效
127 | 3. 数据库文件路径正确
128 | 4. 网络连接正常
129 | 5. 会话ID需要有足够的权限
130 |
131 | ## 错误排查
132 |
133 | 1. **无激活密钥**: 检查配置文件或命令行参数
134 | 2. **连接失败**: 检查API服务是否运行
135 | 3. **数据库错误**: 检查数据库文件路径和权限
136 | 4. **权限不足**: 检查会话ID是否有效
137 |
138 | ## 示例输出
139 |
140 | ```
141 | 2024-01-01 08:00:00 - INFO - ==================================================
142 | 2024-01-01 08:00:00 - INFO - 开始PikPak自动激活任务
143 | 2024-01-01 08:00:00 - INFO - ==================================================
144 | 2024-01-01 08:00:00 - INFO - 激活条件: 上次激活时间为前一天12点后 且 激活次数<3次
145 | 2024-01-01 08:00:00 - INFO - 配置信息: 暂停时间=10-30秒, 最大重试=3次
146 | 2024-01-01 08:00:00 - INFO - 找到 5 个符合条件的账号(激活次数<3且上次激活时间>前一天12点)
147 | 2024-01-01 08:00:00 - INFO - ------------------------------
148 | 2024-01-01 08:00:00 - INFO - 处理第 1/5 个账号: user1
149 | 2024-01-01 08:00:00 - INFO - 邮箱: user1@example.com
150 | 2024-01-01 08:00:00 - INFO - 当前激活次数: 1
151 | 2024-01-01 08:00:00 - INFO - 上次激活时间: 2023-12-31 13:30:00
152 | 2024-01-01 08:00:00 - INFO - 正在激活账号: user1
153 | 2024-01-01 08:00:01 - INFO - ✓ 账号 user1 激活成功 (激活次数: 1 -> 2)
154 | 2024-01-01 08:00:01 - INFO - 暂停 15 秒后继续...
155 | 2024-01-01 08:00:16 - INFO - ==================================================
156 | 2024-01-01 08:00:16 - INFO - 激活任务完成
157 | 2024-01-01 08:00:16 - INFO - 总处理账号: 5 个
158 | 2024-01-01 08:00:16 - INFO - 激活成功: 5 个
159 | 2024-01-01 08:00:16 - INFO - 激活失败: 0 个
160 | 2024-01-01 08:00:16 - INFO - 跳过账号: 0 个
161 | 2024-01-01 08:00:16 - INFO - ==================================================
162 | ```
--------------------------------------------------------------------------------
/activate_tool/activate.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 | """
4 | PikPak 自动激活脚本
5 | 功能:查询激活时间超过一天的账号,并调用激活接口进行激活
6 | """
7 |
8 | import time
9 | import json
10 | import random
11 | import logging
12 | import requests
13 | import sqlite3
14 | import os
15 | from datetime import datetime, timedelta
16 | from typing import List, Dict, Any, Optional
17 |
18 | # 配置日志
19 | logging.basicConfig(
20 | level=logging.INFO,
21 | format='%(asctime)s - %(levelname)s - %(message)s',
22 | handlers=[
23 | logging.FileHandler('activate.log', encoding='utf-8'),
24 | logging.StreamHandler()
25 | ]
26 | )
27 | logger = logging.getLogger(__name__)
28 |
29 | class PikPakActivator:
30 | """PikPak自动激活器"""
31 |
32 | def __init__(self, config: Optional[Dict] = None):
33 | """初始化激活器"""
34 | if not config:
35 | config = self.load_config()
36 |
37 | self.db_path = config.get('db_path', 'accounts.db')
38 | self.api_base_url = config.get('api_base_url', 'http://localhost:5000')
39 | self.activation_key = config.get('activation_key')
40 | self.session_id = config.get('session_id', 'auto_activator')
41 | self.min_sleep_seconds = config.get('min_sleep_seconds', 10)
42 | self.max_sleep_seconds = config.get('max_sleep_seconds', 30)
43 | self.max_retries = config.get('max_retries', 3)
44 | self.retry_delay_seconds = config.get('retry_delay_seconds', 5)
45 | self.max_activation_count = config.get('max_activation_count', 3)
46 |
47 | def load_config(self) -> Dict:
48 | """加载配置文件, 会被args覆盖"""
49 | config_file = 'activate_config.json'
50 | if os.path.exists(config_file):
51 | try:
52 | with open(config_file, 'r', encoding='utf-8') as f:
53 | config = json.load(f)
54 | logger.info(f"已加载配置文件: {config_file}")
55 | return config
56 | except Exception as e:
57 | logger.warning(f"加载配置文件失败: {e}")
58 |
59 | logger.info("使用默认配置")
60 | return {}
61 |
62 | def is_admin_session(self) -> bool:
63 | """通过API检查当前会话是否为管理员会话"""
64 | try:
65 | url = f"{self.api_base_url}/api/session/check_admin"
66 | headers = {
67 | 'Content-Type': 'application/json',
68 | 'X-Session-ID': self.session_id
69 | }
70 |
71 | data = {
72 | "session_id": self.session_id
73 | }
74 |
75 | response = requests.post(url, json=data, headers=headers, timeout=10)
76 |
77 | if response.status_code == 200:
78 | result = response.json()
79 | if result.get('status') == 'success':
80 | is_admin = result.get('is_admin', False)
81 | logger.info(f"API权限检查结果: {result.get('message', '')}")
82 | return is_admin
83 | else:
84 | logger.warning(f"API权限检查失败: {result.get('message', '未知错误')}")
85 | return False
86 | else:
87 | logger.warning(f"API权限检查请求失败: HTTP {response.status_code}")
88 | return False
89 |
90 | except Exception as e:
91 | logger.warning(f"调用API检查管理员权限时发生异常: {e}")
92 | # 检查失败直接按照非管理员处理
93 | return False
94 |
95 | def get_accounts_need_activation(self) -> List[Dict[str, Any]]:
96 | """获取需要激活的账号(上次激活时间为前一天12点后且激活次数小于3的)"""
97 | try:
98 | conn = sqlite3.connect(self.db_path)
99 | cursor = conn.cursor()
100 |
101 | # 检查会话ID是否为管理员
102 | is_admin = self.is_admin_session()
103 |
104 | # 计算前一天12点的时间戳
105 | yesterday = datetime.now() - timedelta(days=1)
106 | yesterday_noon = yesterday.replace(hour=12, minute=0, second=0, microsecond=0)
107 | check_timestamp = yesterday_noon.strftime('%Y-%m-%d %H:%M:%S')
108 |
109 | logger.info(f"会话ID: {self.session_id} ({'管理员' if is_admin else '普通用户'})")
110 | logger.info(f"查询条件: 上次激活时间晚于 {check_timestamp} 且激活次数小于{self.max_activation_count}次")
111 |
112 | # 根据会话权限构建不同的查询
113 | if is_admin:
114 | # 管理员可以激活所有符合条件的账号
115 | logger.info("管理员权限: 查询所有符合条件的账号")
116 | cursor.execute('''
117 | SELECT id, email, name, activation_status, last_activation_time, account_data, session_id
118 | FROM accounts
119 | WHERE (
120 | (activation_status IS NULL OR activation_status < ?) AND
121 | (
122 | last_activation_time IS NULL OR
123 | last_activation_time > ?
124 | )
125 | )
126 | AND email IS NOT NULL
127 | AND email != ''
128 | ORDER BY session_id ASC, activation_status ASC, last_activation_time ASC, created_at ASC
129 | ''', (self.max_activation_count, check_timestamp))
130 | else:
131 | # 普通用户只能激活自己会话的账号
132 | logger.info(f"普通用户权限: 只查询会话 {self.session_id} 的账号")
133 | cursor.execute('''
134 | SELECT id, email, name, activation_status, last_activation_time, account_data, session_id
135 | FROM accounts
136 | WHERE session_id = ? AND (
137 | (activation_status IS NULL OR activation_status < ?) AND
138 | (
139 | last_activation_time IS NULL OR
140 | last_activation_time > ?
141 | )
142 | )
143 | AND email IS NOT NULL
144 | AND email != ''
145 | ORDER BY activation_status ASC, last_activation_time ASC, created_at ASC
146 | ''', (self.session_id, self.max_activation_count, check_timestamp))
147 |
148 | rows = cursor.fetchall()
149 | accounts = []
150 |
151 | for row in rows:
152 | account_data = json.loads(row[5]) if row[5] else {}
153 | account = {
154 | 'id': row[0],
155 | 'email': row[1],
156 | 'name': row[2],
157 | 'activation_status': row[3] if row[3] is not None else 0,
158 | 'last_activation_time': row[4],
159 | 'session_id': row[6] if len(row) > 6 else None,
160 | **account_data
161 | }
162 | accounts.append(account)
163 |
164 | conn.close()
165 | logger.info(f"找到 {len(accounts)} 个符合条件的账号(激活次数<{self.max_activation_count}且上次激活时间>前一天12点)")
166 |
167 | # 输出详细信息便于调试
168 | for account in accounts[:5]: # 只显示前5个账号的详细信息
169 | session_info = f", 会话: {account.get('session_id', '未知')}" if is_admin else ""
170 | logger.info(f" 账号: {account['name']}, 激活次数: {account['activation_status']}, 上次激活: {account['last_activation_time'] or '从未激活'}{session_info}")
171 |
172 | if len(accounts) > 5:
173 | logger.info(f" ... 还有 {len(accounts) - 5} 个账号")
174 |
175 | return accounts
176 |
177 | except Exception as e:
178 | logger.error(f"查询需要激活的账号失败: {e}")
179 | return []
180 |
181 | def activate_account(self, account_name: str) -> bool:
182 | """激活单个账号(支持重试)"""
183 | for attempt in range(self.max_retries):
184 | try:
185 | if not self.activation_key:
186 | logger.error("未设置激活密钥")
187 | return False
188 |
189 | url = f"{self.api_base_url}/api/activate_account_with_names"
190 | headers = {
191 | 'Content-Type': 'application/json',
192 | 'X-Session-ID': self.session_id
193 | }
194 |
195 | data = {
196 | "key": self.activation_key,
197 | "names": [account_name],
198 | "all": False
199 | }
200 |
201 | if attempt > 0:
202 | logger.info(f"重试激活账号: {account_name} (第{attempt+1}次尝试)")
203 | else:
204 | logger.info(f"正在激活账号: {account_name}")
205 |
206 | response = requests.post(url, json=data, headers=headers, timeout=60)
207 |
208 | if response.status_code == 200:
209 | result = response.json()
210 | if result.get('status') == 'success':
211 | results = result.get('results', [])
212 | if results:
213 | first_result = results[0]
214 | if first_result.get('status') == 'success':
215 | logger.info(f"账号 {account_name} 激活成功")
216 | return True
217 | else:
218 | error_msg = first_result.get('message', '未知错误')
219 | if attempt < self.max_retries - 1:
220 | logger.warning(f"账号 {account_name} 激活失败: {error_msg},将重试")
221 | time.sleep(self.retry_delay_seconds)
222 | continue
223 | else:
224 | logger.warning(f"账号 {account_name} 激活失败: {error_msg}")
225 | return False
226 | else:
227 | if attempt < self.max_retries - 1:
228 | logger.warning(f"账号 {account_name} 激活响应为空,将重试")
229 | time.sleep(self.retry_delay_seconds)
230 | continue
231 | else:
232 | logger.warning(f"账号 {account_name} 激活响应为空")
233 | return False
234 | else:
235 | error_msg = result.get('message', '未知错误')
236 | if attempt < self.max_retries - 1:
237 | logger.warning(f"账号 {account_name} 激活失败: {error_msg},将重试")
238 | time.sleep(self.retry_delay_seconds)
239 | continue
240 | else:
241 | logger.warning(f"账号 {account_name} 激活失败: {error_msg}")
242 | return False
243 | else:
244 | if attempt < self.max_retries - 1:
245 | logger.warning(f"账号 {account_name} 激活请求失败: HTTP {response.status_code},将重试")
246 | time.sleep(self.retry_delay_seconds)
247 | continue
248 | else:
249 | logger.error(f"账号 {account_name} 激活请求失败: HTTP {response.status_code}")
250 | return False
251 |
252 | except Exception as e:
253 | if attempt < self.max_retries - 1:
254 | logger.warning(f"激活账号 {account_name} 时发生异常: {e},将重试")
255 | time.sleep(self.retry_delay_seconds)
256 | continue
257 | else:
258 | logger.error(f"激活账号 {account_name} 时发生异常: {e}")
259 | return False
260 |
261 | return False
262 |
263 | def run_activation(self):
264 | """运行激活任务"""
265 | logger.info("=" * 50)
266 | logger.info("开始PikPak自动激活任务")
267 | logger.info("=" * 50)
268 | logger.info(f"激活条件: 上次激活时间为前一天12点后 且 激活次数<{self.max_activation_count}次")
269 | logger.info(f"配置信息: 暂停时间={self.min_sleep_seconds}-{self.max_sleep_seconds}秒, 最大重试={self.max_retries}次")
270 |
271 | if not self.activation_key:
272 | logger.error("请设置激活密钥")
273 | return
274 |
275 | # 获取需要激活的账号
276 | accounts = self.get_accounts_need_activation()
277 |
278 | if not accounts:
279 | logger.info("没有符合条件的账号需要激活")
280 | return
281 |
282 | success_count = 0
283 | failed_count = 0
284 | skipped_count = 0
285 |
286 | for i, account in enumerate(accounts):
287 | account_name = account.get('name') or account.get('email', '').split('@')[0]
288 | activation_status = account.get('activation_status', 0)
289 |
290 | logger.info("-" * 30)
291 | logger.info(f"处理第 {i+1}/{len(accounts)} 个账号: {account_name}")
292 | logger.info(f"邮箱: {account.get('email')}")
293 | logger.info(f"当前激活次数: {activation_status}")
294 | logger.info(f"上次激活时间: {account.get('last_activation_time', '从未激活')}")
295 |
296 | # 再次检查激活次数限制(双重保险)
297 | if activation_status >= self.max_activation_count:
298 | logger.warning(f"账号 {account_name} 激活次数已达限制({activation_status}>={self.max_activation_count}),跳过")
299 | skipped_count += 1
300 | continue
301 |
302 | # 激活账号
303 | if self.activate_account(account_name):
304 | success_count += 1
305 | new_status = activation_status + 1
306 | logger.info(f"✓ 账号 {account_name} 激活成功 (激活次数: {activation_status} -> {new_status})")
307 | else:
308 | failed_count += 1
309 | logger.warning(f"✗ 账号 {account_name} 激活失败")
310 |
311 | # 如果不是最后一个账号,暂停随机时间
312 | if i < len(accounts) - 1:
313 | sleep_time = random.randint(self.min_sleep_seconds, self.max_sleep_seconds)
314 | logger.info(f"暂停 {sleep_time} 秒后继续...")
315 | time.sleep(sleep_time)
316 |
317 | logger.info("=" * 50)
318 | logger.info(f"激活任务完成")
319 | logger.info(f"总处理账号: {len(accounts)} 个")
320 | logger.info(f"激活成功: {success_count} 个")
321 | logger.info(f"激活失败: {failed_count} 个")
322 | logger.info(f"跳过账号: {skipped_count} 个")
323 | logger.info("=" * 50)
324 |
325 | # 记录统计信息到日志
326 | if success_count > 0 or failed_count > 0 or skipped_count > 0:
327 | with open('activation_stats.log', 'a', encoding='utf-8') as f:
328 | f.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - 处理:{len(accounts)}, 成功:{success_count}, 失败:{failed_count}, 跳过:{skipped_count}\n")
329 |
330 | def main():
331 | """主函数"""
332 | import argparse
333 |
334 | parser = argparse.ArgumentParser(description='PikPak 自动激活脚本')
335 | parser.add_argument('--key', '-k', help='激活密钥')
336 | parser.add_argument('--db', '-d', help='数据库文件路径')
337 | parser.add_argument('--url', '-u', help='API服务地址')
338 | parser.add_argument('--session', '-s', help='会话ID')
339 | parser.add_argument('--config', '-c', help='配置文件路径')
340 | parser.add_argument('--max-activations', '-m', type=int, help='最大激活次数限制')
341 |
342 | args = parser.parse_args()
343 |
344 | # 加载配置
345 | config = {}
346 | if args.config and os.path.exists(args.config):
347 | with open(args.config, 'r', encoding='utf-8') as f:
348 | config = json.load(f)
349 |
350 | # 命令行参数覆盖配置文件
351 | if args.key:
352 | config['activation_key'] = args.key
353 | if args.db:
354 | config['db_path'] = args.db
355 | if args.url:
356 | config['api_base_url'] = args.url
357 | if args.session:
358 | config['session_id'] = args.session
359 | if getattr(args, 'max_activations', None):
360 | config['max_activation_count'] = args.max_activations
361 |
362 | # 创建激活器
363 | activator = PikPakActivator(config)
364 |
365 | # 运行激活任务
366 | activator.run_activation()
367 |
368 | if __name__ == "__main__":
369 | main()
--------------------------------------------------------------------------------
/activate_tool/activate_config.json.example:
--------------------------------------------------------------------------------
1 | {
2 | "activation_key": "Acce113945",
3 | "api_base_url": "http://localhost:5000",
4 | "session_id": "admin",
5 | "db_path": "../accounts.db",
6 | "min_sleep_seconds": 10,
7 | "max_sleep_seconds": 30,
8 | "max_retries": 3,
9 | "retry_delay_seconds": 5,
10 | "max_activation_count": 3
11 | }
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 |
3 | services:
4 | pikpak-auto:
5 | image: vichus/pikpak-invitation:latest
6 | container_name: pikpak-auto
7 | ports:
8 | - "5000:5000"
9 | environment:
10 | - MAIL_POINT_API_URL=https://your-endpoint.com
11 | - ADMIN_SESSION_ID=your_admin_session_id
12 | volumes:
13 | - ./account:/app/account
14 | restart: unless-stopped
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/frontend/README.md:
--------------------------------------------------------------------------------
1 | # React + TypeScript + Vite
2 |
3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4 |
5 | Currently, two official plugins are available:
6 |
7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9 |
10 | ## Expanding the ESLint configuration
11 |
12 | If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
13 |
14 | ```js
15 | export default tseslint.config({
16 | extends: [
17 | // Remove ...tseslint.configs.recommended and replace with this
18 | ...tseslint.configs.recommendedTypeChecked,
19 | // Alternatively, use this for stricter rules
20 | ...tseslint.configs.strictTypeChecked,
21 | // Optionally, add this for stylistic rules
22 | ...tseslint.configs.stylisticTypeChecked,
23 | ],
24 | languageOptions: {
25 | // other options...
26 | parserOptions: {
27 | project: ['./tsconfig.node.json', './tsconfig.app.json'],
28 | tsconfigRootDir: import.meta.dirname,
29 | },
30 | },
31 | })
32 | ```
33 |
34 | You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
35 |
36 | ```js
37 | // eslint.config.js
38 | import reactX from 'eslint-plugin-react-x'
39 | import reactDom from 'eslint-plugin-react-dom'
40 |
41 | export default tseslint.config({
42 | plugins: {
43 | // Add the react-x and react-dom plugins
44 | 'react-x': reactX,
45 | 'react-dom': reactDom,
46 | },
47 | rules: {
48 | // other rules...
49 | // Enable its recommended typescript rules
50 | ...reactX.configs['recommended-typescript'].rules,
51 | ...reactDom.configs.recommended.rules,
52 | },
53 | })
54 | ```
55 |
--------------------------------------------------------------------------------
/frontend/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js'
2 | import globals from 'globals'
3 | import reactHooks from 'eslint-plugin-react-hooks'
4 | import reactRefresh from 'eslint-plugin-react-refresh'
5 | import tseslint from 'typescript-eslint'
6 |
7 | export default tseslint.config(
8 | { ignores: ['dist'] },
9 | {
10 | extends: [js.configs.recommended, ...tseslint.configs.recommended],
11 | files: ['**/*.{ts,tsx}'],
12 | languageOptions: {
13 | ecmaVersion: 2020,
14 | globals: globals.browser,
15 | },
16 | plugins: {
17 | 'react-hooks': reactHooks,
18 | 'react-refresh': reactRefresh,
19 | },
20 | rules: {
21 | ...reactHooks.configs.recommended.rules,
22 | 'react-refresh/only-export-components': [
23 | 'warn',
24 | { allowConstantExport: true },
25 | ],
26 | },
27 | },
28 | )
29 |
--------------------------------------------------------------------------------
/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | PikPak 自助邀请助手
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc -b && vite build",
9 | "lint": "eslint .",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@ant-design/icons": "^6.0.0",
14 | "@ant-design/v5-patch-for-react-19": "^1.0.3",
15 | "antd": "^5.24.9",
16 | "axios": "^1.9.0",
17 | "react": "^19.0.0",
18 | "react-dom": "^19.0.0",
19 | "react-router-dom": "^7.5.3"
20 | },
21 | "devDependencies": {
22 | "@eslint/js": "^9.22.0",
23 | "@types/react": "^19.0.10",
24 | "@types/react-dom": "^19.0.4",
25 | "@vitejs/plugin-react": "^4.3.4",
26 | "eslint": "^9.22.0",
27 | "eslint-plugin-react-hooks": "^5.2.0",
28 | "eslint-plugin-react-refresh": "^0.4.19",
29 | "globals": "^16.0.0",
30 | "typescript": "~5.7.2",
31 | "typescript-eslint": "^8.26.1",
32 | "vite": "^6.3.1"
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/frontend/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/src/App.css:
--------------------------------------------------------------------------------
1 | /* App.css */
2 | * {
3 | margin: 0;
4 | padding: 0;
5 | box-sizing: border-box;
6 | }
7 |
8 | body {
9 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
10 | 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
11 | 'Noto Color Emoji';
12 | background-color: #f0f2f5;
13 | }
14 |
15 | /* Remove old layout styles */
16 | /*
17 | .app-container {
18 | display: flex;
19 | flex-direction: column;
20 | min-height: 100vh;
21 | }
22 |
23 | .content-container {
24 | flex: 1;
25 | padding: 20px;
26 | }
27 |
28 | @media (max-width: 768px) {
29 | .content-container {
30 | padding: 10px;
31 | }
32 | }
33 | */
34 |
35 | /* Remove or comment out #root restrictions to allow full width */
36 | /*
37 | #root {
38 | max-width: 1280px;
39 | margin: 0 auto;
40 | padding: 2rem;
41 | text-align: center;
42 | }
43 | */
44 |
45 | /* Add styles for the logo in the sidebar */
46 | .sidebar-logo {
47 | height: 32px;
48 | margin: 16px;
49 | background: rgba(255, 255, 255, 0.2);
50 | border-radius: 4px;
51 | text-align: center;
52 | line-height: 32px;
53 | color: white;
54 | font-weight: bold;
55 | overflow: hidden;
56 | white-space: nowrap; /* Prevent text wrap when collapsed */
57 | }
58 |
59 | /* Add styles for fixed sidebar and scrollable content */
60 | .ant-layout-sider-children {
61 | overflow-y: hidden !important; /* Prevent sidebar content from scrolling */
62 | }
63 |
64 | .site-layout-background {
65 | overflow-y: auto;
66 | overflow-x: hidden; /* Hide horizontal scrollbar */
67 | }
68 |
69 | /* Remove the overflow: hidden that's preventing scrolling */
70 | /* html, body {
71 | overflow: hidden;
72 | } */
73 |
74 | /* Responsive adjustments for mobile */
75 | @media (max-width: 768px) {
76 | .ant-layout-sider {
77 | position: absolute !important;
78 | z-index: 100;
79 | }
80 |
81 | .ant-layout-content {
82 | margin-left: 0 !important;
83 | padding-left: 0 !important;
84 | }
85 | }
86 |
87 | /* Remove the rule for the now-deleted .site-layout element */
88 | /*
89 | .site-layout {
90 | flex: 1;
91 | }
92 | */
93 |
94 | /* Keep other potentially useful styles if needed, e.g., card, logo animation, etc. */
95 | /* These might be template defaults or used elsewhere, review if needed */
96 | .logo {
97 | height: 6em;
98 | padding: 1.5em;
99 | will-change: filter;
100 | transition: filter 300ms;
101 | }
102 | .logo:hover {
103 | filter: drop-shadow(0 0 2em #646cffaa);
104 | }
105 | .logo.react:hover {
106 | filter: drop-shadow(0 0 2em #61dafbaa);
107 | }
108 |
109 | @keyframes logo-spin {
110 | from {
111 | transform: rotate(0deg);
112 | }
113 | to {
114 | transform: rotate(360deg);
115 | }
116 | }
117 |
118 | @media (prefers-reduced-motion: no-preference) {
119 | a:nth-of-type(2) .logo {
120 | animation: logo-spin infinite 20s linear;
121 | }
122 | }
123 |
124 | .card {
125 | padding: 2em;
126 | }
127 |
128 | .read-the-docs {
129 | color: #888;
130 | }
131 |
--------------------------------------------------------------------------------
/frontend/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import { BrowserRouter as Router, Routes, Route, Navigate, useLocation, Link, Outlet } from 'react-router-dom';
3 | import { ConfigProvider, Layout, Menu, Button, Space, Typography } from 'antd';
4 | import {
5 | UserAddOutlined,
6 | CheckCircleOutlined,
7 | HistoryOutlined,
8 | UserOutlined,
9 | SwapOutlined,
10 | GlobalOutlined,
11 | IdcardOutlined
12 | } from '@ant-design/icons';
13 | import zhCN from 'antd/lib/locale/zh_CN';
14 | import './App.css';
15 |
16 | // 导入页面组件 (needed in MainLayout)
17 | import Register from './pages/Register';
18 | import Activate from './pages/Activate';
19 | import History from './pages/History';
20 | import ProxyPool from './pages/ProxyPool';
21 | import AccountManager from './pages/AccountManager';
22 |
23 | // 导入会话管理组件
24 | import SessionManager from './components/SessionManager';
25 | import SessionInitializer from './components/SessionInitializer';
26 |
27 | const { Sider, Content } = Layout;
28 | const { Text } = Typography;
29 |
30 | // Define the new MainLayout component
31 | const MainLayout: React.FC = () => {
32 | const [collapsed, setCollapsed] = useState(false); // Move state here
33 | const [sessionId, setSessionId] = useState('');
34 | const [isAdmin, setIsAdmin] = useState(false);
35 | const [showSessionManager, setShowSessionManager] = useState(false);
36 | const [showSessionInitializer, setShowSessionInitializer] = useState(false);
37 |
38 | const location = useLocation(); // Move hook call here
39 | const currentPath = location.pathname;
40 |
41 | // 检查会话状态
42 | useEffect(() => {
43 | const checkSession = async () => {
44 | const storedSessionId = localStorage.getItem('session_id');
45 |
46 | if (!storedSessionId) {
47 | setShowSessionInitializer(true);
48 | return;
49 | }
50 |
51 | try {
52 | const response = await fetch('/api/session/validate', {
53 | method: 'POST',
54 | headers: {
55 | 'Content-Type': 'application/json',
56 | },
57 | body: JSON.stringify({ session_id: storedSessionId }),
58 | });
59 |
60 | const data = await response.json();
61 | if (data.status === 'success' && data.is_valid) {
62 | setSessionId(storedSessionId);
63 | setIsAdmin(data.is_admin);
64 | } else {
65 | localStorage.removeItem('session_id');
66 | setShowSessionInitializer(true);
67 | }
68 | } catch (error) {
69 | console.error('验证会话失败:', error);
70 | localStorage.removeItem('session_id');
71 | setShowSessionInitializer(true);
72 | }
73 | };
74 |
75 | checkSession();
76 | }, []);
77 |
78 | const handleSessionCreated = (newSessionId: string, adminStatus: boolean) => {
79 | setSessionId(newSessionId);
80 | setIsAdmin(adminStatus);
81 | setShowSessionInitializer(false);
82 | };
83 |
84 | const handleSessionChange = (newSessionId: string, adminStatus: boolean) => {
85 | setSessionId(newSessionId);
86 | setIsAdmin(adminStatus);
87 | // 刷新页面以重新加载数据
88 | window.location.reload();
89 | };
90 |
91 | // Move menu items definition here
92 | const items = [
93 | {
94 | key: '/register',
95 | icon: ,
96 | label: 账号注册,
97 | },
98 | {
99 | key: '/activate',
100 | icon: ,
101 | label: 账号激活,
102 | },
103 | {
104 | key: '/history',
105 | icon: ,
106 | label: 历史账号,
107 | },
108 | {
109 | key: '/account-manager',
110 | icon: ,
111 | label: 账号信息,
112 | },
113 | // 只有管理员可以看到代理池管理
114 | ...(isAdmin ? [{
115 | key: '/proxy-pool',
116 | icon: ,
117 | label: 代理池管理,
118 | }] : []),
119 | ];
120 |
121 | // Move the Layout JSX structure here
122 | return (
123 | <>
124 |
125 | setCollapsed(value)}
129 | style={{
130 | overflow: 'auto',
131 | height: '100vh',
132 | position: 'fixed',
133 | left: 0,
134 | top: 0,
135 | bottom: 0,
136 | zIndex: 10
137 | }}
138 | >
139 |
140 | {collapsed ? "P" : "PikPak 自动邀请"}
141 |
142 |
143 |
144 | {/* 会话信息和管理按钮 */}
145 | {sessionId && (
146 |
157 | {!collapsed ? (
158 |
159 |
166 | 会话: {sessionId.length > 12 ? sessionId.substring(0, 12) + '...' : sessionId}
167 | {isAdmin && (管理员)}
168 |
169 | }
172 | onClick={(e) => {
173 | e.preventDefault();
174 | e.stopPropagation();
175 | setShowSessionManager(true);
176 | }}
177 | style={{
178 | width: '100%',
179 | pointerEvents: 'auto',
180 | fontSize: '11px',
181 | height: '24px'
182 | }}
183 | >
184 | 切换会话
185 |
186 |
187 | ) : (
188 |
189 |
195 | {sessionId.substring(0, 6)}...
196 | {isAdmin && ★}
197 |
198 | }
201 | onClick={(e) => {
202 | e.preventDefault();
203 | e.stopPropagation();
204 | setShowSessionManager(true);
205 | }}
206 | style={{
207 | width: '100%',
208 | pointerEvents: 'auto',
209 | height: '20px',
210 | fontSize: '10px'
211 | }}
212 | title="会话管理"
213 | />
214 |
215 | )}
216 |
217 | )}
218 |
219 |
224 |
225 |
235 | {/* Outlet用于渲染子路由 */}
236 |
237 |
238 |
239 |
240 |
241 |
242 | {/* 会话管理弹窗 */}
243 | setShowSessionManager(false)}
246 | onSessionChange={handleSessionChange}
247 | currentSessionId={sessionId}
248 | isAdmin={isAdmin}
249 | />
250 |
251 | {/* 会话初始化弹窗 */}
252 |
256 | >
257 | );
258 | };
259 |
260 | // Simplify the App component
261 | function App() {
262 | return (
263 |
280 |
281 |
282 | }>
283 | } />
284 | } />
285 | } />
286 | } />
287 | } />
288 | } />
289 |
290 |
291 |
292 |
293 | );
294 | }
295 |
296 | export default App;
297 |
--------------------------------------------------------------------------------
/frontend/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/src/components/Header/index.css:
--------------------------------------------------------------------------------
1 | /* Comment out or remove old container style
2 | .header-container {
3 | display: flex;
4 | align-items: center;
5 | padding: 0 24px;
6 | background-color: #001529;
7 | color: white;
8 | height: 64px;
9 | }
10 | */
11 |
12 | /* Add new style for Layout.Header */
13 | .header-layout {
14 | display: flex; /* Use flexbox for alignment */
15 | align-items: center; /* Vertically center items */
16 | padding: 0 30px; /* Adjust horizontal padding */
17 | }
18 |
19 | .logo {
20 | font-size: 20px;
21 | font-weight: bold;
22 | margin-right: 30px; /* Adjust margin for spacing */
23 | }
24 |
25 | .logo a {
26 | /* color: white; Remove this, Layout.Header theme handles it */
27 | text-decoration: none;
28 | }
29 |
30 | .header-menu {
31 | flex: 1; /* Keep this to fill remaining space */
32 | }
--------------------------------------------------------------------------------
/frontend/src/components/Header/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Menu, Layout } from 'antd';
3 | import { Link, useLocation } from 'react-router-dom';
4 | import './index.css';
5 |
6 | const Header: React.FC = () => {
7 | const location = useLocation();
8 | const currentPath = location.pathname;
9 |
10 | const items = [
11 | {
12 | key: '/register',
13 | label: 账号注册,
14 | },
15 | {
16 | key: '/activate',
17 | label: 账号激活,
18 | },
19 | {
20 | key: '/history',
21 | label: 历史账号,
22 | },
23 | ];
24 |
25 | return (
26 |
27 |
28 | PikPak 自动邀请
29 |
30 |
37 |
38 | );
39 | };
40 |
41 | export default Header;
--------------------------------------------------------------------------------
/frontend/src/components/SessionInitializer.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { Modal, Input, Button, message, Space, Typography, Card, Alert, Tabs } from 'antd';
3 | import { UserOutlined, KeyOutlined, PlusOutlined, LoginOutlined } from '@ant-design/icons';
4 |
5 | const { Title, Text, Paragraph } = Typography;
6 | const { TabPane } = Tabs;
7 |
8 | interface SessionInitializerProps {
9 | visible: boolean;
10 | onSessionCreated: (sessionId: string, isAdmin: boolean) => void;
11 | }
12 |
13 | const SessionInitializer: React.FC = ({
14 | visible,
15 | onSessionCreated
16 | }) => {
17 | const [activeTab, setActiveTab] = useState('create');
18 | const [customSessionId, setCustomSessionId] = useState(''); // 用于自定义会话ID
19 | const [existingSessionId, setExistingSessionId] = useState(''); // 用于现有会话ID
20 | const [sessionLength, setSessionLength] = useState(12);
21 | const [loading, setLoading] = useState(false);
22 |
23 | const generateSessionId = async () => {
24 | setLoading(true);
25 | try {
26 | const response = await fetch('/api/session/generate', {
27 | method: 'POST',
28 | headers: {
29 | 'Content-Type': 'application/json',
30 | },
31 | body: JSON.stringify({ length: sessionLength }),
32 | });
33 |
34 | const data = await response.json();
35 | if (data.status === 'success') {
36 | const newSessionId = data.session_id;
37 |
38 | // 保存到localStorage
39 | localStorage.setItem('session_id', newSessionId);
40 |
41 | message.success('会话创建成功!');
42 | onSessionCreated(newSessionId, false);
43 | } else {
44 | message.error(data.message || '创建会话失败');
45 | }
46 | } catch (error) {
47 | message.error('创建会话失败');
48 | } finally {
49 | setLoading(false);
50 | }
51 | };
52 |
53 | // 创建自定义会话ID
54 | const createCustomSession = async () => {
55 | if (!customSessionId.trim()) {
56 | message.error('请输入会话ID');
57 | return;
58 | }
59 |
60 | const trimmedSessionId = customSessionId.trim();
61 |
62 | // 验证会话ID格式
63 | if (trimmedSessionId.length < 6 || trimmedSessionId.length > 20) {
64 | message.error('会话ID长度必须在6-20位之间');
65 | return;
66 | }
67 |
68 | if (!/^[a-zA-Z0-9]+$/.test(trimmedSessionId)) {
69 | message.error('会话ID只能包含字母和数字');
70 | return;
71 | }
72 |
73 | setLoading(true);
74 | try {
75 | // 直接创建自定义会话ID
76 | const response = await fetch('/api/session/generate', {
77 | method: 'POST',
78 | headers: {
79 | 'Content-Type': 'application/json',
80 | },
81 | body: JSON.stringify({ custom_id: trimmedSessionId }),
82 | });
83 |
84 | const data = await response.json();
85 | if (data.status === 'success') {
86 | localStorage.setItem('session_id', trimmedSessionId);
87 | message.success('自定义会话创建成功!');
88 | onSessionCreated(trimmedSessionId, false);
89 | } else {
90 | message.error(data.message || '创建自定义会话失败');
91 | }
92 | } catch (error) {
93 | message.error('创建自定义会话失败');
94 | } finally {
95 | setLoading(false);
96 | }
97 | };
98 |
99 | // 验证现有会话ID
100 | const validateExistingSession = async () => {
101 | if (!existingSessionId.trim()) {
102 | message.error('请输入会话ID');
103 | return;
104 | }
105 |
106 | const trimmedSessionId = existingSessionId.trim();
107 |
108 | // 验证会话ID格式
109 | if (trimmedSessionId.length < 6 || trimmedSessionId.length > 20) {
110 | message.error('会话ID长度必须在6-20位之间');
111 | return;
112 | }
113 |
114 | if (!/^[a-zA-Z0-9]+$/.test(trimmedSessionId)) {
115 | message.error('会话ID只能包含字母和数字');
116 | return;
117 | }
118 |
119 | setLoading(true);
120 | try {
121 | const response = await fetch('/api/session/validate', {
122 | method: 'POST',
123 | headers: {
124 | 'Content-Type': 'application/json',
125 | },
126 | body: JSON.stringify({ session_id: trimmedSessionId }),
127 | });
128 |
129 | const data = await response.json();
130 | if (data.status === 'success' && data.is_valid) {
131 | // 保存到localStorage
132 | localStorage.setItem('session_id', trimmedSessionId);
133 |
134 | message.success(`会话验证成功${data.is_admin ? ' (管理员模式)' : ''}!`);
135 | onSessionCreated(trimmedSessionId, data.is_admin);
136 | } else {
137 | message.error(data.message || '会话ID无效');
138 | }
139 | } catch (error) {
140 | message.error('验证会话ID失败');
141 | } finally {
142 | setLoading(false);
143 | }
144 | };
145 |
146 | return (
147 |
150 |
151 | 欢迎使用 PikPak 自动邀请系统
152 |
153 | }
154 | open={visible}
155 | closable={false}
156 | maskClosable={false}
157 | footer={null}
158 | width={600}
159 | >
160 |
300 | }
301 | type="warning"
302 | showIcon
303 | style={{ marginTop: 24 }}
304 | />
305 |
306 |
307 | );
308 | };
309 |
310 | export default SessionInitializer;
--------------------------------------------------------------------------------
/frontend/src/components/SessionManager.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import { Modal, Input, Button, message, Space, Typography, Card, Divider, Alert } from 'antd';
3 | import { UserOutlined, KeyOutlined, SwapOutlined, PlusOutlined } from '@ant-design/icons';
4 |
5 | const { Title, Text } = Typography;
6 |
7 | interface SessionManagerProps {
8 | visible: boolean;
9 | onClose: () => void;
10 | onSessionChange: (sessionId: string, isAdmin: boolean) => void;
11 | currentSessionId?: string;
12 | isAdmin?: boolean;
13 | }
14 |
15 | const SessionManager: React.FC = ({
16 | visible,
17 | onClose,
18 | onSessionChange,
19 | currentSessionId,
20 | isAdmin
21 | }) => {
22 | const [sessionId, setSessionId] = useState('');
23 | const [sessionLength, setSessionLength] = useState(12);
24 | const [loading, setLoading] = useState(false);
25 |
26 | useEffect(() => {
27 | if (visible && currentSessionId) {
28 | setSessionId(currentSessionId);
29 | }
30 | }, [visible, currentSessionId]);
31 |
32 | const generateSessionId = async () => {
33 | setLoading(true);
34 | try {
35 | const response = await fetch('/api/session/generate', {
36 | method: 'POST',
37 | headers: {
38 | 'Content-Type': 'application/json',
39 | },
40 | body: JSON.stringify({ length: sessionLength }),
41 | });
42 |
43 | const data = await response.json();
44 | if (data.status === 'success') {
45 | setSessionId(data.session_id);
46 | message.success('会话ID生成成功');
47 | } else {
48 | message.error(data.message || '生成会话ID失败');
49 | }
50 | } catch (error) {
51 | message.error('生成会话ID失败');
52 | } finally {
53 | setLoading(false);
54 | }
55 | };
56 |
57 | const validateAndSwitchSession = async () => {
58 | if (!sessionId.trim()) {
59 | message.error('请输入会话ID');
60 | return;
61 | }
62 |
63 | setLoading(true);
64 | try {
65 | const response = await fetch('/api/session/validate', {
66 | method: 'POST',
67 | headers: {
68 | 'Content-Type': 'application/json',
69 | },
70 | body: JSON.stringify({ session_id: sessionId.trim() }),
71 | });
72 |
73 | const data = await response.json();
74 | if (data.status === 'success' && data.is_valid) {
75 | // 保存到localStorage
76 | localStorage.setItem('session_id', sessionId.trim());
77 |
78 | // 通知父组件会话已切换
79 | onSessionChange(sessionId.trim(), data.is_admin);
80 |
81 | message.success(`会话切换成功${data.is_admin ? ' (管理员模式)' : ''}`);
82 | onClose();
83 | } else {
84 | message.error(data.message || '会话ID无效');
85 | }
86 | } catch (error) {
87 | message.error('验证会话ID失败');
88 | } finally {
89 | setLoading(false);
90 | }
91 | };
92 |
93 | const handleCancel = () => {
94 | setSessionId(currentSessionId || '');
95 | onClose();
96 | };
97 |
98 | return (
99 |
102 |
103 | 会话管理
104 |
105 | }
106 | open={visible}
107 | onCancel={handleCancel}
108 | footer={null}
109 | width={500}
110 | destroyOnClose
111 | >
112 |
113 | {/* 当前会话信息 */}
114 | {currentSessionId && (
115 |
116 |
117 | 当前会话
118 |
119 |
120 | {currentSessionId}
121 | {isAdmin && (管理员)}
122 |
123 |
124 |
125 | )}
126 |
127 |
134 |
135 | {/* 会话ID输入 */}
136 |
137 |
138 | 切换会话
139 |
140 |
141 | setSessionId(e.target.value)}
145 | maxLength={20}
146 | prefix={}
147 | onPressEnter={validateAndSwitchSession}
148 | />
149 |
150 | }
156 | >
157 | 切换到此会话
158 |
159 |
160 |
161 |
162 |
163 | {/* 生成新会话 */}
164 |
165 |
166 | 创建新会话
167 |
168 |
169 | 自动生成:
170 |
171 | 长度:
172 | setSessionLength(Number(e.target.value))}
178 | style={{ width: 80 }}
179 | />
180 | 位
181 |
182 |
183 | }
188 | >
189 | 生成随机会话ID
190 |
191 |
192 |
193 |
194 | );
195 | };
196 |
197 | export default SessionManager;
--------------------------------------------------------------------------------
/frontend/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
3 | line-height: 1.5;
4 | font-weight: 400;
5 |
6 | color-scheme: light dark;
7 | color: rgba(255, 255, 255, 0.87);
8 | background-color: #242424;
9 |
10 | font-synthesis: none;
11 | text-rendering: optimizeLegibility;
12 | -webkit-font-smoothing: antialiased;
13 | -moz-osx-font-smoothing: grayscale;
14 | }
15 |
16 | a {
17 | font-weight: 500;
18 | color: #646cff;
19 | text-decoration: inherit;
20 | }
21 | a:hover {
22 | color: #535bf2;
23 | }
24 |
25 | body {
26 | margin: 0;
27 | display: flex;
28 | place-items: center;
29 | min-width: 320px;
30 | min-height: 100vh;
31 | }
32 |
33 | h1 {
34 | font-size: 3.2em;
35 | line-height: 1.1;
36 | }
37 |
38 | /* Override Ant Design button hover styles */
39 | .ant-btn-primary {
40 | transition: opacity 0.2s !important;
41 | box-shadow: none !important;
42 | }
43 |
44 | .ant-btn-primary:hover,
45 | .ant-btn-primary:focus,
46 | .ant-btn-primary:active {
47 | opacity: 0.9;
48 | box-shadow: none !important;
49 | transform: none !important;
50 | }
51 |
52 | button {
53 | border-radius: 8px;
54 | border: 1px solid transparent;
55 | padding: 0.6em 1.2em;
56 | font-size: 1em;
57 | font-weight: 500;
58 | font-family: inherit;
59 | background-color: #1a1a1a;
60 | cursor: pointer;
61 | transition: border-color 0.25s;
62 | }
63 | button:hover {
64 | border-color: #646cff;
65 | }
66 | button:focus,
67 | button:focus-visible {
68 | outline: 4px auto -webkit-focus-ring-color;
69 | }
70 |
71 | @media (prefers-color-scheme: light) {
72 | :root {
73 | color: #213547;
74 | background-color: #ffffff;
75 | }
76 | a:hover {
77 | color: #747bff;
78 | }
79 | button {
80 | background-color: #f9f9f9;
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/frontend/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import './index.css'
4 | import App from './App.tsx'
5 | import '@ant-design/v5-patch-for-react-19'
6 |
7 | createRoot(document.getElementById('root')!).render(
8 |
9 |
10 | ,
11 | )
12 |
--------------------------------------------------------------------------------
/frontend/src/pages/AccountManager/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect, useRef } from 'react';
2 | import {
3 | Table,
4 | Card,
5 | Button,
6 | Space,
7 | Modal,
8 | message,
9 | Tooltip,
10 | Badge,
11 | Typography,
12 | Tag,
13 | Drawer,
14 | Descriptions,
15 | Spin,
16 | Alert,
17 | Input,
18 | InputRef
19 | } from 'antd';
20 | import {
21 | ReloadOutlined,
22 | KeyOutlined,
23 | TeamOutlined,
24 | CrownOutlined,
25 | SearchOutlined
26 | } from '@ant-design/icons';
27 | import { fetchAccounts, getAccountVipInfo, getAccountInviteCode, getAccountInviteList } from '../../services/api';
28 |
29 | const { Text, Title } = Typography;
30 |
31 | const AccountManager: React.FC = () => {
32 | const [accounts, setAccounts] = useState([]);
33 | const [filteredAccounts, setFilteredAccounts] = useState([]);
34 | const [loading, setLoading] = useState(false);
35 | const [vipModalVisible, setVipModalVisible] = useState(false);
36 | const [inviteCodeModalVisible, setInviteCodeModalVisible] = useState(false);
37 | const [inviteListDrawerVisible, setInviteListDrawerVisible] = useState(false);
38 | const [currentAccount, setCurrentAccount] = useState(null);
39 | const [vipInfo, setVipInfo] = useState(null);
40 | const [inviteCode, setInviteCode] = useState('');
41 | const [inviteList, setInviteList] = useState([]);
42 | const [actionLoading, setActionLoading] = useState(false);
43 | const [inviteListLoading, setInviteListLoading] = useState(false);
44 | const [inviteListError, setInviteListError] = useState(null);
45 | const [_, setInviteListInfo] = useState(null);
46 | const [searchText, setSearchText] = useState('');
47 | const searchInputRef = useRef(null);
48 |
49 | // 加载账号列表
50 | const loadAccounts = async () => {
51 | setLoading(true);
52 | try {
53 | const response = await fetchAccounts();
54 | if (response.data.status === 'success') {
55 | const accountData = response.data.accounts || [];
56 | setAccounts(accountData);
57 | setFilteredAccounts(accountData);
58 | } else {
59 | message.error(response.data.message || '获取账号列表失败');
60 | }
61 | } catch (error) {
62 | console.error('获取账号列表失败:', error);
63 | message.error('获取账号列表失败');
64 | } finally {
65 | setLoading(false);
66 | }
67 | };
68 |
69 | // 初始加载
70 | useEffect(() => {
71 | loadAccounts();
72 | }, []);
73 |
74 | // 搜索过滤
75 | useEffect(() => {
76 | if (searchText) {
77 | const filtered = accounts.filter(account =>
78 | account.email.toLowerCase().includes(searchText.toLowerCase())
79 | );
80 | setFilteredAccounts(filtered);
81 | } else {
82 | setFilteredAccounts(accounts);
83 | }
84 | }, [searchText, accounts]);
85 |
86 | // 查询VIP信息
87 | const handleViewVipInfo = async (account: any) => {
88 | setCurrentAccount(account);
89 | setVipInfo(null);
90 | setVipModalVisible(true);
91 | setActionLoading(true);
92 |
93 | try {
94 | const response = await getAccountVipInfo(account);
95 | if (response.data.status === 'success') {
96 | setVipInfo(response.data.data);
97 | } else {
98 | message.error(response.data.message || '获取VIP信息失败');
99 | }
100 | } catch (error) {
101 | console.error('获取VIP信息失败:', error);
102 | message.error('获取VIP信息失败');
103 | } finally {
104 | setActionLoading(false);
105 | }
106 | };
107 |
108 | // 查看邀请码
109 | const handleViewInviteCode = async (account: any) => {
110 | setCurrentAccount(account);
111 | setInviteCode('');
112 | setInviteCodeModalVisible(true);
113 | setActionLoading(true);
114 |
115 | try {
116 | const response = await getAccountInviteCode(account);
117 | if (response.data.status === 'success') {
118 | setInviteCode(response.data.data.code || '');
119 | } else {
120 | message.error(response.data.message || '获取邀请码失败');
121 | }
122 | } catch (error) {
123 | console.error('获取邀请码失败:', error);
124 | message.error('获取邀请码失败');
125 | } finally {
126 | setActionLoading(false);
127 | }
128 | };
129 |
130 | // 查看邀请记录
131 | const handleViewInviteList = async (account: any) => {
132 | setCurrentAccount(account);
133 | setInviteList([]);
134 | setInviteListInfo(null);
135 | setInviteListDrawerVisible(true);
136 | setActionLoading(true);
137 | setInviteListLoading(true);
138 | setInviteListError(null);
139 |
140 | try {
141 | const response = await getAccountInviteList(account);
142 |
143 | if (response.data.status === 'success') {
144 | const inviteData = response.data.data?.data || [];
145 |
146 | // 直接设置两个state
147 | setInviteList(inviteData);
148 | setInviteListInfo(response.data.data ? response.data : { data: { data: inviteData } });
149 |
150 | if (inviteData.length === 0) {
151 | message.info('暂无邀请记录');
152 | }
153 | } else {
154 | const errorMsg = response.data.message || '获取邀请记录失败';
155 | message.error(errorMsg);
156 | setInviteListError(errorMsg);
157 | }
158 | } catch (error) {
159 | console.error('获取邀请记录失败:', error);
160 | message.error('获取邀请记录失败');
161 | setInviteListError('获取邀请记录失败');
162 | } finally {
163 | setActionLoading(false);
164 | setInviteListLoading(false);
165 | }
166 | };
167 |
168 | // 复制邀请码到剪贴板
169 | const copyInviteCode = () => {
170 | if (inviteCode) {
171 | navigator.clipboard.writeText(inviteCode)
172 | .then(() => message.success('邀请码已复制到剪贴板'))
173 | .catch(() => message.error('复制失败,请手动复制'));
174 | }
175 | };
176 |
177 | // 邀请记录抽屉内容
178 | const renderInviteList = () => {
179 | if (inviteListLoading) {
180 | return ;
181 | }
182 |
183 | if (inviteListError) {
184 | return ;
185 | }
186 |
187 | // 数据为空时显示提示
188 | if (!inviteList || inviteList.length === 0) {
189 | return ;
190 | }
191 |
192 | // 简化列定义,只保留最基本的列
193 | const columns = [
194 | {
195 | title: '邮箱',
196 | dataIndex: 'invited_user',
197 | key: 'invited_user',
198 | },
199 | {
200 | title: '邀请时间',
201 | dataIndex: 'time',
202 | key: 'time',
203 | render: (time: string) => time ? new Date(time).toLocaleString() : '-'
204 | },
205 | {
206 | title: '奖励天数',
207 | dataIndex: 'reward_days',
208 | key: 'reward_days'
209 | },
210 | {
211 | title: '状态',
212 | dataIndex: 'order_status',
213 | key: 'order_status',
214 | render: (status: string, record: any) => (
215 |
216 | {status === 'present' ? '已生效' : (record.delay ? '延迟中' : status)}
217 |
218 | )
219 | },
220 | {
221 | title: '激活状态',
222 | dataIndex: 'activation_status',
223 | key: 'activation_status',
224 | render: (status: number) => (
225 | 0 ? "success" : "default"}
227 | text={status > 0 ? `已激活(${status}次)` : "未激活"}
228 | />
229 | )
230 | }
231 | ];
232 |
233 | // 添加调试信息
234 | return (
235 | <>
236 |
243 |
244 |
251 | >
252 | );
253 | };
254 |
255 | // 表格列定义
256 | const columns = [
257 | {
258 | title: '邮箱',
259 | dataIndex: 'email',
260 | key: 'email',
261 | render: (text: string) => {text},
262 | filterDropdown: () => (
263 |
264 | setSearchText(e.target.value)}
269 | onPressEnter={() => searchInputRef.current?.blur()}
270 | style={{ width: 188, marginBottom: 8, display: 'block' }}
271 | />
272 |
273 |
282 |
291 |
292 |
293 | ),
294 | filterIcon: (filtered: boolean) => (
295 |
296 | ),
297 | },
298 | {
299 | title: '创建时间',
300 | dataIndex: 'created_at',
301 | key: 'created_at',
302 | sorter: (a: any, b: any) => {
303 | const timeA = a.created_at ? new Date(a.created_at).getTime() : 0;
304 | const timeB = b.created_at ? new Date(b.created_at).getTime() : 0;
305 | return timeA - timeB;
306 | },
307 | render: (time: string) => time ? new Date(time).toLocaleString() : '-'
308 | },
309 | {
310 | title: '激活次数',
311 | dataIndex: 'activation_status',
312 | key: 'activation_status',
313 | sorter: (a: any, b: any) => (a.activation_status || 0) - (b.activation_status || 0),
314 | defaultSortOrder: 'descend' as const,
315 | render: (status: number) => status > 0 ? status : 0
316 | },
317 | {
318 | title: '最后激活时间',
319 | dataIndex: 'last_activation_time',
320 | key: 'last_activation_time',
321 | sorter: (a: any, b: any) => {
322 | const timeA = a.last_activation_time ? new Date(a.last_activation_time).getTime() : 0;
323 | const timeB = b.last_activation_time ? new Date(b.last_activation_time).getTime() : 0;
324 | return timeA - timeB;
325 | },
326 | render: (time: string) => time ? new Date(time).toLocaleString() : '未激活'
327 | },
328 | {
329 | title: '状态',
330 | dataIndex: 'activation_status',
331 | key: 'status',
332 | render: (status: number) => (
333 | 0 ? "success" : "default"}
335 | text={status > 0 ? `已激活` : "未激活"}
336 | />
337 | )
338 | },
339 | {
340 | title: '操作',
341 | key: 'action',
342 | render: (_: any, record: any) => (
343 |
344 |
345 | }
347 | size="small"
348 | onClick={() => handleViewVipInfo(record)}
349 | />
350 |
351 |
352 | }
354 | size="small"
355 | onClick={() => handleViewInviteCode(record)}
356 | />
357 |
358 |
359 | }
361 | size="small"
362 | onClick={() => handleViewInviteList(record)}
363 | />
364 |
365 |
366 | ),
367 | },
368 | ];
369 |
370 | return (
371 |
372 |
376 | setSearchText(e.target.value)}
380 | style={{ width: 200 }}
381 | allowClear
382 | prefix={}
383 | />
384 | }
387 | onClick={loadAccounts}
388 | loading={loading}
389 | >
390 | 刷新
391 |
392 |
393 | }
394 | >
395 |
418 |
419 |
420 | {/* VIP信息弹窗 */}
421 |
VIP信息>}
423 | open={vipModalVisible}
424 | onCancel={() => setVipModalVisible(false)}
425 | footer={[
426 |
429 | ]}
430 | >
431 | {actionLoading ? (
432 |
436 | ) : (
437 | vipInfo ? (
438 |
439 |
440 | {currentAccount?.email}
441 |
442 |
443 | {vipInfo.data?.status === 'ok' ? (
444 | 有效
445 | ) : (
446 | 无效
447 | )}
448 |
449 |
450 | {vipInfo.data?.type === 'platinum' ? '白金会员' :
451 | vipInfo.data?.type === 'gold' ? '黄金会员' :
452 | vipInfo.data?.type === 'novip' ? '非会员' : vipInfo.data?.type}
453 |
454 | {vipInfo.data?.expire && (
455 |
456 | {new Date(vipInfo.data.expire).toLocaleString()}
457 |
458 | )}
459 |
460 | ) : (
461 |
462 | )
463 | )}
464 |
465 |
466 | {/* 邀请码弹窗 */}
467 |
邀请码>}
469 | open={inviteCodeModalVisible}
470 | onCancel={() => setInviteCodeModalVisible(false)}
471 | footer={[
472 | ,
475 |
478 | ]}
479 | >
480 | {actionLoading ? (
481 |
485 | ) : (
486 | inviteCode ? (
487 |
488 |
{inviteCode}
489 | 这是账号 {currentAccount?.email} 的邀请码
490 |
491 | ) : (
492 |
493 | )
494 | )}
495 |
496 |
497 | {/* 邀请记录抽屉 */}
498 |
邀请记录>}
500 | width={720}
501 | open={inviteListDrawerVisible}
502 | onClose={() => setInviteListDrawerVisible(false)}
503 | extra={
504 |
512 | }
513 | >
514 | {renderInviteList()}
515 |
516 |
517 | );
518 | };
519 |
520 | export default AccountManager;
--------------------------------------------------------------------------------
/frontend/src/pages/Activate/index.css:
--------------------------------------------------------------------------------
1 | .activate-container {
2 | max-width: 1400px;
3 | margin: 0 auto;
4 | padding: 20px;
5 | min-height: calc(100vh - 80px);
6 | overflow: visible; /* Allow proper scrolling */
7 | }
8 |
9 | .activate-card {
10 | border-radius: 12px;
11 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
12 | overflow: hidden;
13 | }
14 |
15 | .key-input-section {
16 | background: #fafafa;
17 | padding: 24px;
18 | border-radius: 8px;
19 | margin-bottom: 24px;
20 | }
21 |
22 | .filter-section {
23 | background: #f9f9f9;
24 | padding: 20px;
25 | border-radius: 8px;
26 | margin-bottom: 24px;
27 | border: 1px solid #e8e8e8;
28 | }
29 |
30 | .filter-section .ant-divider {
31 | margin: 0 0 16px 0;
32 | }
33 |
34 | .filter-section .ant-divider-inner-text {
35 | font-weight: 600;
36 | color: #1890ff;
37 | }
38 |
39 | .accounts-section {
40 | background: white;
41 | border-radius: 8px;
42 | }
43 |
44 | .accounts-section .ant-table {
45 | border-radius: 8px;
46 | overflow: hidden;
47 | }
48 |
49 | .accounts-section .ant-table-thead > tr > th {
50 | background: #fafafa;
51 | font-weight: 600;
52 | border-bottom: 2px solid #e8e8e8;
53 | }
54 |
55 | .accounts-section .ant-table-tbody > tr:hover > td {
56 | background: #f0f9ff;
57 | }
58 |
59 | .loading-section {
60 | text-align: center;
61 | padding: 60px 0;
62 | background: #fafafa;
63 | border-radius: 8px;
64 | }
65 |
66 | .loading-section .ant-spin {
67 | margin-bottom: 16px;
68 | }
69 |
70 | .results-section {
71 | background: white;
72 | padding: 24px;
73 | border-radius: 8px;
74 | }
75 |
76 | .results-section .ant-result {
77 | padding: 24px 0;
78 | }
79 |
80 | .results-section .ant-statistic {
81 | text-align: center;
82 | padding: 16px;
83 | background: #fafafa;
84 | border-radius: 8px;
85 | margin-bottom: 16px;
86 | }
87 |
88 | .results-section .ant-table {
89 | border-radius: 8px;
90 | overflow: hidden;
91 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
92 | }
93 |
94 | /* 响应式设计 */
95 | @media (max-width: 768px) {
96 | .activate-container {
97 | padding: 10px;
98 | }
99 |
100 | .key-input-section,
101 | .filter-section {
102 | padding: 16px;
103 | }
104 |
105 | .filter-section .ant-row {
106 | flex-direction: column;
107 | }
108 |
109 | .filter-section .ant-col {
110 | margin-bottom: 12px;
111 | width: 100% !important;
112 | }
113 | }
114 |
115 | /* 自定义标签样式 */
116 | .ant-tag {
117 | border-radius: 6px;
118 | font-weight: 500;
119 | }
120 |
121 | /* 自定义按钮样式 */
122 | .ant-btn {
123 | border-radius: 6px;
124 | font-weight: 500;
125 | }
126 |
127 | .ant-btn:focus,
128 | .ant-btn:active {
129 | outline: none;
130 | box-shadow: none;
131 | border-color: transparent;
132 | }
133 |
134 | .ant-btn-primary {
135 | background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
136 | border: none;
137 | }
138 |
139 | .ant-btn-primary:hover {
140 | background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%);
141 | transform: translateY(-1px);
142 | box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
143 | }
144 |
145 | .ant-btn-primary:focus,
146 | .ant-btn-primary:active {
147 | background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
148 | outline: none;
149 | box-shadow: none;
150 | border-color: transparent;
151 | }
152 |
153 | /* Ghost按钮样式 */
154 | .ant-btn-primary.ant-btn-background-ghost {
155 | background: transparent;
156 | border-color: #667eea;
157 | color: #667eea;
158 | }
159 |
160 | .ant-btn-primary.ant-btn-background-ghost:hover {
161 | background: #667eea;
162 | border-color: #667eea;
163 | color: white;
164 | transform: translateY(-1px);
165 | box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
166 | }
167 |
168 | .ant-btn-primary.ant-btn-background-ghost:focus,
169 | .ant-btn-primary.ant-btn-background-ghost:active {
170 | background: transparent;
171 | border-color: #667eea;
172 | color: #667eea;
173 | outline: none;
174 | box-shadow: none;
175 | }
176 |
177 | /* 表格行选择样式 */
178 | .ant-table-tbody > tr.ant-table-row-selected > td {
179 | background: #e6f7ff;
180 | }
181 |
182 | .ant-table-tbody > tr.ant-table-row-selected:hover > td {
183 | background: #bae7ff;
184 | }
185 |
186 | /* 统计卡片动画 */
187 | .ant-statistic {
188 | transition: all 0.3s ease;
189 | }
190 |
191 | .ant-statistic:hover {
192 | transform: translateY(-2px);
193 | }
194 |
195 | /* 筛选区域动画 */
196 | .filter-section {
197 | transition: all 0.3s ease;
198 | }
199 |
200 | .filter-section:hover {
201 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
202 | }
203 |
204 | /* 加载动画增强 */
205 | .loading-section .ant-spin-dot {
206 | font-size: 24px;
207 | }
208 |
209 | /* 结果页面样式增强 */
210 | .results-section .ant-result-icon {
211 | margin-bottom: 24px;
212 | }
213 |
214 | .results-section .ant-result-title {
215 | color: #1890ff;
216 | font-weight: 600;
217 | }
218 |
219 | .results-section .ant-result-subtitle {
220 | color: #666;
221 | font-size: 16px;
222 | }
--------------------------------------------------------------------------------
/frontend/src/pages/History/index.css:
--------------------------------------------------------------------------------
1 | .history-container {
2 | max-width: 1000px;
3 | margin: 0 auto;
4 | overflow: visible; /* Allow proper scrolling */
5 | }
6 |
7 | .history-card {
8 | width: 100%;
9 |
10 | }
11 |
12 | .history-card .ant-card-body {
13 | padding: 0;
14 | }
15 |
16 | .account-details {
17 | margin-top: 16px;
18 | }
19 |
20 | .token-container {
21 | max-width: 100%;
22 | overflow-x: auto;
23 | overflow-y: hidden;
24 | padding: 8px;
25 | background-color: #f5f5f5;
26 | border-radius: 4px;
27 | margin-top: 4px;
28 | word-break: break-all;
29 | white-space: normal;
30 | font-family: monospace;
31 | }
--------------------------------------------------------------------------------
/frontend/src/pages/History/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import { Table, Card, Button, message, Modal, Typography, Tag, Space, Popconfirm } from 'antd';
3 | import { ReloadOutlined, DeleteOutlined, InfoCircleOutlined } from '@ant-design/icons';
4 | import { fetchAccounts as apiFetchAccounts, deleteAccount, deleteAccounts } from '../../services/api';
5 | import './index.css';
6 |
7 | const { Text, Paragraph } = Typography;
8 |
9 | interface AccountInfo {
10 | id?: number;
11 | name?: string;
12 | email?: string;
13 | password?: string;
14 | user_id?: string;
15 | device_id?: string;
16 | version?: string;
17 | access_token?: string;
18 | refresh_token?: string;
19 | filename: string;
20 | captcha_token?: string;
21 | timestamp?: number;
22 | invite_code?: string; // 新增邀请码字段
23 | activation_status?: number; // 激活状态:0=未激活,1+=激活次数
24 | last_activation_time?: string; // 最后激活时间
25 | session_id?: string;
26 | created_at?: string;
27 | updated_at?: string;
28 | }
29 |
30 | const History: React.FC = () => {
31 | const [accounts, setAccounts] = useState([]);
32 | const [loading, setLoading] = useState(false);
33 | const [visible, setVisible] = useState(false);
34 | const [currentAccount, setCurrentAccount] = useState(null);
35 | const [selectedRowKeys, setSelectedRowKeys] = useState([]);
36 | const [batchDeleteVisible, setBatchDeleteVisible] = useState(false);
37 | const [batchDeleteLoading, setBatchDeleteLoading] = useState(false);
38 |
39 | // 修改 fetchAccounts 函数以调用 API
40 | const fetchAccounts = async () => {
41 | setLoading(true);
42 | try {
43 | const response = await apiFetchAccounts(); // Call the imported API function
44 | if (response.data && response.data.status === 'success') {
45 | // Map the response to ensure consistency, though AccountInfo is now optional
46 | const fetchedAccounts = response.data.accounts.map((acc: any) => ({
47 | ...acc,
48 | name: acc.name || acc.filename, // Use filename as name if name is missing
49 | }));
50 | setAccounts(fetchedAccounts);
51 | // 清空选择
52 | setSelectedRowKeys([]);
53 | } else {
54 | message.error(response.data.message || '获取账号列表失败');
55 | }
56 | } catch (error: any) {
57 | console.error('获取账号错误:', error);
58 | message.error(`获取账号列表失败: ${error.message || '未知错误'}`);
59 | }
60 | setLoading(false);
61 | };
62 |
63 | useEffect(() => {
64 | fetchAccounts();
65 | }, []);
66 |
67 | const handleDelete = async (accountId: number | string) => {
68 | setLoading(true);
69 | try {
70 | // 调用删除账号API
71 | const response = await deleteAccount(accountId.toString());
72 |
73 | if (response.data && response.data.status === 'success') {
74 | // 从状态中移除账号
75 | setAccounts(prevAccounts => prevAccounts.filter(acc =>
76 | acc.id ? acc.id !== accountId : acc.filename !== accountId
77 | ));
78 | message.success(response.data.message || '账号已成功删除');
79 | } else {
80 | // 显示API返回的错误消息
81 | message.error(response.data.message || '删除账号失败');
82 | }
83 | } catch (error: any) {
84 | console.error('删除账号错误:', error);
85 | // 显示捕获到的错误消息
86 | message.error(`删除账号出错: ${error.message || '未知错误'}`);
87 | } finally {
88 | // 确保 loading 状态在所有情况下都设置为 false
89 | setLoading(false);
90 | }
91 | };
92 |
93 | // 批量删除账号
94 | const handleBatchDelete = async () => {
95 | if (selectedRowKeys.length === 0) {
96 | message.warning('请至少选择一个账号');
97 | return;
98 | }
99 |
100 | setBatchDeleteLoading(true);
101 | try {
102 | // 从选中的键中提取ID
103 | const accountIds = selectedRowKeys.map(key => key.toString());
104 |
105 | // 调用批量删除API
106 | const response = await deleteAccounts(accountIds);
107 |
108 | if (response.data && (response.data.status === 'success' || response.data.status === 'partial')) {
109 | // 从状态中移除成功删除的账号
110 | if (response.data.results && response.data.results.success) {
111 | const successIds = response.data.results.success;
112 | setAccounts(prevAccounts =>
113 | prevAccounts.filter(acc => !successIds.includes(acc.id?.toString()))
114 | );
115 | }
116 |
117 | // 显示成功消息
118 | message.success(response.data.message || '账号已成功删除');
119 |
120 | // 清空选择
121 | setSelectedRowKeys([]);
122 | } else {
123 | // 显示API返回的错误消息
124 | message.error(response.data.message || '批量删除账号失败');
125 | }
126 | } catch (error: any) {
127 | console.error('批量删除账号错误:', error);
128 | message.error(`批量删除账号出错: ${error.message || '未知错误'}`);
129 | } finally {
130 | setBatchDeleteLoading(false);
131 | setBatchDeleteVisible(false); // 关闭确认对话框
132 | }
133 | };
134 |
135 | const showAccountDetails = (account: AccountInfo) => {
136 | setCurrentAccount(account);
137 | setVisible(true);
138 | };
139 |
140 | // 表格行选择配置
141 | const rowSelection = {
142 | selectedRowKeys,
143 | onChange: (newSelectedRowKeys: React.Key[]) => {
144 | setSelectedRowKeys(newSelectedRowKeys);
145 | }
146 | };
147 |
148 | const columns = [
149 | {
150 | title: '名称',
151 | dataIndex: 'name',
152 | key: 'name',
153 | },
154 | {
155 | title: '邮箱',
156 | dataIndex: 'email',
157 | key: 'email',
158 | },
159 | {
160 | title: '状态',
161 | key: 'status',
162 | render: (_: any, record: AccountInfo) => {
163 | const activationStatus = record.activation_status || 0;
164 |
165 | if (activationStatus === 0) {
166 | return 未激活;
167 | } else if (activationStatus === 1) {
168 | return 已激活 (1次);
169 | } else if (activationStatus > 1) {
170 | return 已激活 ({activationStatus}次);
171 | } else {
172 | // 兼容旧数据
173 | if (record.access_token) {
174 | return 已激活;
175 | } else if (record.email) {
176 | return 未激活;
177 | } else {
178 | return 信息不完整;
179 | }
180 | }
181 | },
182 | },
183 | {
184 | title: '邀请码',
185 | dataIndex: 'invite_code',
186 | key: 'invite_code',
187 | render: (invite_code?: string) => invite_code || '-',
188 | },
189 | {
190 | title: '修改日期',
191 | dataIndex: 'timestamp',
192 | key: 'timestamp',
193 | render: (timestamp: number) => {
194 | // 这里需要类型转换
195 | return (new Date(timestamp*1)).toLocaleString();
196 | },
197 | },
198 | {
199 | title: '操作',
200 | key: 'action',
201 | render: (_: any, record: AccountInfo) => {
202 | const isIncomplete = !record.email; // Consider incomplete if email is missing
203 | return (
204 |
205 | }
208 | onClick={() => showAccountDetails(record)}
209 | disabled={isIncomplete} // Disable if incomplete
210 | >
211 | 详情
212 |
213 | handleDelete(record.id || record.filename)}
216 | okText="确定"
217 | cancelText="取消"
218 | >
219 | }>
220 | 删除
221 |
222 |
223 |
224 | );
225 | },
226 | },
227 | ];
228 |
229 | return (
230 |
231 |
236 | {selectedRowKeys.length > 0 && (
237 | }
240 | onClick={() => setBatchDeleteVisible(true)}
241 | >
242 | 批量删除 ({selectedRowKeys.length})
243 |
244 | )}
245 | }
248 | onClick={fetchAccounts}
249 | loading={loading}
250 | >
251 | 刷新
252 |
253 |
254 | }
255 | >
256 | record.id?.toString() || record.filename}
261 | loading={loading}
262 | pagination={{ pageSize: 10 }}
263 | />
264 |
265 |
266 | {/* 账号详情模态框 */}
267 | setVisible(false)}
271 | footer={[
272 |
275 | ]}
276 | width={700}
277 | >
278 | {currentAccount && (
279 |
280 |
281 | 名称: {currentAccount.name || '未提供'}
282 |
283 |
284 | 邮箱: {currentAccount.email || '未提供'}
285 |
286 |
287 | 密码: {currentAccount.password || '未提供'}
288 |
289 |
290 | 用户ID: {currentAccount.user_id || '未提供'}
291 |
292 |
293 | 设备ID: {currentAccount.device_id || '未提供'}
294 |
295 |
296 | 版本: {currentAccount.version || '未提供'}
297 |
298 |
299 | Access Token:
300 |
301 | {currentAccount.access_token || '无'}
302 |
303 |
304 |
305 | Refresh Token:
306 |
307 | {currentAccount.refresh_token || '无'}
308 |
309 |
310 |
311 | 邀请码: {currentAccount.invite_code || '未提供'}
312 |
313 |
314 | 文件名: {currentAccount.filename}
315 |
316 |
317 | )}
318 |
319 |
320 | {/* 批量删除确认对话框 */}
321 | setBatchDeleteVisible(false)}
325 | footer={[
326 | ,
329 |
338 | ]}
339 | >
340 | 确定要删除选中的 {selectedRowKeys.length} 个账号吗?此操作不可撤销。
341 |
342 |
343 | );
344 | };
345 |
346 | export default History;
--------------------------------------------------------------------------------
/frontend/src/pages/ProxyPool/index.css:
--------------------------------------------------------------------------------
1 | .proxy-pool-container {
2 | padding: 24px;
3 | background: #f5f5f5;
4 | min-height: calc(100vh - 48px); /* Subtract padding from viewport height */
5 | overflow: visible; /* Allow proper scrolling */
6 | }
7 |
8 | .proxy-pool-container .ant-card {
9 | border-radius: 8px;
10 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
11 | }
12 |
13 | .proxy-pool-container .ant-table-thead > tr > th {
14 | background: #fafafa;
15 | font-weight: 600;
16 | }
17 |
18 | .proxy-pool-container .ant-table-tbody > tr:hover > td {
19 | background: #f5f5f5;
20 | }
21 |
22 | .proxy-pool-container .ant-tag {
23 | border-radius: 4px;
24 | font-size: 12px;
25 | }
26 |
27 | .proxy-pool-container .ant-btn-sm {
28 | height: 24px;
29 | padding: 0 7px;
30 | font-size: 12px;
31 | }
32 |
33 | .proxy-pool-container .ant-modal-body {
34 | padding: 24px;
35 | }
36 |
37 | .proxy-pool-container .ant-progress {
38 | margin-bottom: 8px;
39 | }
40 |
41 | .proxy-pool-container .ant-alert {
42 | border-radius: 6px;
43 | }
44 |
45 | .proxy-pool-container .ant-typography {
46 | margin-bottom: 0;
47 | }
48 |
49 | .proxy-pool-container .ant-space-item {
50 | display: flex;
51 | align-items: center;
52 | }
53 |
54 | /* 响应式设计 */
55 | @media (max-width: 768px) {
56 | .proxy-pool-container {
57 | padding: 16px;
58 | }
59 |
60 | .proxy-pool-container .ant-table {
61 | font-size: 12px;
62 | }
63 |
64 | .proxy-pool-container .ant-btn {
65 | padding: 4px 8px;
66 | font-size: 12px;
67 | }
68 | }
--------------------------------------------------------------------------------
/frontend/src/pages/ProxyPool/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import {
3 | Card,
4 | Table,
5 | Button,
6 | Input,
7 | message,
8 | Modal,
9 | Space,
10 | Tag,
11 | Popconfirm,
12 | Typography,
13 | Alert,
14 | Tooltip,
15 | Progress
16 | } from 'antd';
17 | import {
18 | PlusOutlined,
19 | DeleteOutlined,
20 | ReloadOutlined,
21 | ExperimentOutlined,
22 | CheckCircleOutlined,
23 | CloseCircleOutlined,
24 | } from '@ant-design/icons';
25 | import { HttpClient } from '../../utils/httpClient';
26 | import './index.css';
27 |
28 | const { Text, Title } = Typography;
29 | const { TextArea } = Input;
30 |
31 | interface ProxyInfo {
32 | id: number;
33 | proxy_url: string;
34 | protocol: string;
35 | host: string;
36 | port: number;
37 | username?: string;
38 | password?: string;
39 | is_active: boolean;
40 | last_checked?: string;
41 | response_time?: number;
42 | success_count: number;
43 | fail_count: number;
44 | created_at: string;
45 | updated_at: string;
46 | }
47 |
48 | const ProxyPool: React.FC = () => {
49 | const [proxies, setProxies] = useState([]);
50 | const [loading, setLoading] = useState(false);
51 | const [addModalVisible, setAddModalVisible] = useState(false);
52 | const [batchAddModalVisible, setBatchAddModalVisible] = useState(false);
53 | const [newProxyUrl, setNewProxyUrl] = useState('');
54 | const [batchProxyUrls, setBatchProxyUrls] = useState('');
55 | const [testingAll, setTestingAll] = useState(false);
56 | const [testProgress, setTestProgress] = useState(0);
57 | const [testingProxies, setTestingProxies] = useState>(new Set());
58 |
59 | useEffect(() => {
60 | fetchProxies();
61 | }, []);
62 |
63 | const fetchProxies = async () => {
64 | setLoading(true);
65 | try {
66 | const response = await HttpClient.get('/api/proxy/list');
67 | const data = await response.json();
68 |
69 | if (data.status === 'success') {
70 | setProxies(data.proxies);
71 | } else {
72 | message.error(data.message || '获取代理列表失败');
73 | }
74 | } catch (error) {
75 | message.error('获取代理列表失败');
76 | } finally {
77 | setLoading(false);
78 | }
79 | };
80 |
81 | const addProxy = async () => {
82 | if (!newProxyUrl.trim()) {
83 | message.error('请输入代理URL');
84 | return;
85 | }
86 |
87 | try {
88 | const response = await HttpClient.post('/api/proxy/add', {
89 | proxy_url: newProxyUrl.trim()
90 | });
91 | const data = await response.json();
92 |
93 | if (data.status === 'success') {
94 | message.success('代理添加成功');
95 | setNewProxyUrl('');
96 | setAddModalVisible(false);
97 | fetchProxies();
98 | } else {
99 | message.error(data.message || '添加代理失败');
100 | }
101 | } catch (error) {
102 | message.error('添加代理失败');
103 | }
104 | };
105 |
106 | const batchAddProxies = async () => {
107 | const urls = batchProxyUrls.split('\n').filter(url => url.trim());
108 | if (urls.length === 0) {
109 | message.error('请输入代理URL');
110 | return;
111 | }
112 |
113 | let successCount = 0;
114 | let failCount = 0;
115 |
116 | for (const url of urls) {
117 | try {
118 | const response = await HttpClient.post('/api/proxy/add', {
119 | proxy_url: url.trim()
120 | });
121 | const data = await response.json();
122 |
123 | if (data.status === 'success') {
124 | successCount++;
125 | } else {
126 | failCount++;
127 | }
128 | } catch (error) {
129 | failCount++;
130 | }
131 | }
132 |
133 | message.success(`批量添加完成: 成功 ${successCount} 个,失败 ${failCount} 个`);
134 | setBatchProxyUrls('');
135 | setBatchAddModalVisible(false);
136 | fetchProxies();
137 | };
138 |
139 | const removeProxy = async (proxyId: number) => {
140 | try {
141 | const response = await HttpClient.post('/api/proxy/remove', {
142 | proxy_id: proxyId
143 | });
144 | const data = await response.json();
145 |
146 | if (data.status === 'success') {
147 | message.success('代理删除成功');
148 | fetchProxies();
149 | } else {
150 | message.error(data.message || '删除代理失败');
151 | }
152 | } catch (error) {
153 | message.error('删除代理失败');
154 | }
155 | };
156 |
157 | const testProxy = async (proxyId: number, proxyUrl: string) => {
158 | // 添加到测试中的代理集合
159 | setTestingProxies(prev => new Set(prev).add(proxyId));
160 |
161 | try {
162 | const response = await HttpClient.post('/api/proxy/test', {
163 | proxy_url: proxyUrl
164 | });
165 | const data = await response.json();
166 |
167 | if (data.status === 'success') {
168 | const result = data.test_result;
169 | if (result.success) {
170 | message.success(`代理测试成功,响应时间: ${result.response_time?.toFixed(2)}s`);
171 | } else {
172 | message.error(`代理测试失败: ${result.error}`);
173 | }
174 | fetchProxies(); // 刷新列表以显示最新状态
175 | } else {
176 | message.error(data.message || '测试代理失败');
177 | }
178 | } catch (error) {
179 | message.error('测试代理失败');
180 | } finally {
181 | // 从测试中的代理集合中移除
182 | setTestingProxies(prev => {
183 | const newSet = new Set(prev);
184 | newSet.delete(proxyId);
185 | return newSet;
186 | });
187 | }
188 | };
189 |
190 | const testAllProxies = async () => {
191 | setTestingAll(true);
192 | setTestProgress(0);
193 |
194 | try {
195 | const response = await HttpClient.post('/api/proxy/test-all');
196 | const data = await response.json();
197 |
198 | if (data.status === 'success') {
199 | const results = data.results;
200 | message.success(`批量测试完成: ${results.success}/${results.total} 成功`);
201 | fetchProxies();
202 | } else {
203 | message.error(data.message || '批量测试失败');
204 | }
205 | } catch (error) {
206 | message.error('批量测试失败');
207 | } finally {
208 | setTestingAll(false);
209 | setTestProgress(0);
210 | }
211 | };
212 |
213 | const columns = [
214 | {
215 | title: 'ID',
216 | dataIndex: 'id',
217 | key: 'id',
218 | width: 60,
219 | },
220 | {
221 | title: '代理地址',
222 | dataIndex: 'proxy_url',
223 | key: 'proxy_url',
224 | ellipsis: true,
225 | render: (text: string) => (
226 |
227 |
228 | {text.length > 40 ? `${text.substring(0, 40)}...` : text}
229 |
230 |
231 | ),
232 | },
233 | {
234 | title: '协议',
235 | dataIndex: 'protocol',
236 | key: 'protocol',
237 | width: 80,
238 | render: (protocol: string) => (
239 |
240 | {protocol.toUpperCase()}
241 |
242 | ),
243 | },
244 | {
245 | title: '状态',
246 | dataIndex: 'is_active',
247 | key: 'is_active',
248 | width: 80,
249 | render: (isActive: boolean) => (
250 | : }>
251 | {isActive ? '活跃' : '不活跃'}
252 |
253 | ),
254 | },
255 | {
256 | title: '响应时间',
257 | dataIndex: 'response_time',
258 | key: 'response_time',
259 | width: 100,
260 | render: (time: number) => (
261 | time ? (
262 |
263 | {time.toFixed(2)}s
264 |
265 | ) : '-'
266 | ),
267 | },
268 | {
269 | title: '成功/失败',
270 | key: 'stats',
271 | width: 100,
272 | render: (record: ProxyInfo) => (
273 |
274 | 成功: {record.success_count}
275 | 失败: {record.fail_count}
276 |
277 | ),
278 | },
279 | {
280 | title: '最后检查',
281 | dataIndex: 'last_checked',
282 | key: 'last_checked',
283 | width: 120,
284 | render: (time: string) => (
285 | time ? (
286 |
287 | {new Date(time).toLocaleString()}
288 |
289 | ) : '-'
290 | ),
291 | },
292 | {
293 | title: '操作',
294 | key: 'actions',
295 | width: 120,
296 | render: (record: ProxyInfo) => (
297 |
298 | }
301 | onClick={() => testProxy(record.id, record.proxy_url)}
302 | loading={testingProxies.has(record.id)}
303 | title="测试代理"
304 | />
305 | removeProxy(record.id)}
308 | okText="确定"
309 | cancelText="取消"
310 | >
311 | }
315 | title="删除代理"
316 | />
317 |
318 |
319 | ),
320 | },
321 | ];
322 |
323 | return (
324 |
325 |
326 |
336 |
337 |
338 |
339 | }
342 | onClick={() => setAddModalVisible(true)}
343 | >
344 | 添加代理
345 |
346 | }
348 | onClick={() => setBatchAddModalVisible(true)}
349 | >
350 | 批量添加
351 |
352 | }
354 | onClick={testAllProxies}
355 | loading={testingAll}
356 | >
357 | 测试所有代理
358 |
359 | }
361 | onClick={fetchProxies}
362 | loading={loading}
363 | >
364 | 刷新
365 |
366 |
367 |
368 |
369 | {testingAll && (
370 |
371 |
372 |
正在测试代理...
373 |
374 | )}
375 |
376 | `共 ${total} 个代理`,
386 | }}
387 | scroll={{ x: 800 }}
388 | />
389 |
390 |
391 | {/* 添加单个代理弹窗 */}
392 | {
397 | setAddModalVisible(false);
398 | setNewProxyUrl('');
399 | }}
400 | okText="添加"
401 | cancelText="取消"
402 | >
403 |
404 |
代理URL格式示例:
405 |
406 |
• http://127.0.0.1:7890
407 |
• https://user:pass@proxy.example.com:8080
408 |
• socks5://user:pass@proxy.example.com:1080
409 |
410 |
411 | setNewProxyUrl(e.target.value)}
415 | onPressEnter={addProxy}
416 | />
417 |
418 |
419 | {/* 批量添加代理弹窗 */}
420 | {
425 | setBatchAddModalVisible(false);
426 | setBatchProxyUrls('');
427 | }}
428 | okText="批量添加"
429 | cancelText="取消"
430 | width={600}
431 | >
432 |
433 | 每行一个代理URL:
434 |
435 |
444 |
445 | );
446 | };
447 |
448 | export default ProxyPool;
--------------------------------------------------------------------------------
/frontend/src/pages/Register/index.css:
--------------------------------------------------------------------------------
1 | .register-container {
2 | display: flex;
3 | justify-content: space-between;
4 | align-items: flex-start;
5 | padding: 10px 20px 10px 10px;
6 | gap: 20px;
7 | height: calc(100vh - 50px);
8 | background-color: #f0f2f5;
9 |
10 | @media screen and (max-width: 1024px) {
11 | flex-direction: column;
12 | height: auto;
13 | }
14 | }
15 |
16 | .register-card {
17 | position: relative;
18 | width: 100%;
19 | max-width: 1000px;
20 | height: 100%;
21 | }
22 |
23 | .register-right {
24 | position: relative;
25 | width: 100%;
26 | max-width: 1000px;
27 | height: calc(100vh - 70px);
28 | display: grid;
29 | grid-template-rows: 49.6% 49.6%;
30 | gap: 5px;
31 | }
32 |
33 | .register-right .register-card {
34 | height: 100%;
35 | }
36 |
37 | .register-card .ant-card-body > .steps-content {
38 | margin-top: 24px;
39 | }
40 |
41 | .steps-action {
42 | margin-top: 24px;
43 | text-align: right;
44 | position: absolute;
45 | bottom: 10px;
46 | right: 10px;
47 | }
48 |
49 | #captcha-container {
50 | margin: 24px 0;
51 | min-height: 150px;
52 | border: 1px solid #eee;
53 | padding: 12px;
54 | display: flex;
55 | justify-content: center;
56 | align-items: center;
57 | }
58 |
59 | .right-panel-list {
60 | max-height: 400px;
61 | overflow-y: auto;
62 | }
63 |
64 | .right-panel-list-item {
65 | margin-bottom: 10px;
66 | padding: 8px;
67 | border: 1px solid #eee;
68 | border-radius: 4px;
69 | transition: background-color 0.3s ease;
70 | }
71 |
72 | .right-panel-list-item.processing {
73 | background-color: #e6f7ff;
74 | }
75 |
76 | .right-panel-list-item-status {
77 | margin-top: 5px;
78 | }
79 |
80 | .right-panel-list-item-message {
81 | margin-top: 5px;
82 | font-size: 12px;
83 | word-break: break-all;
84 | }
85 |
86 | .right-panel-list-item-message.error {
87 | color: #ff4d4f;
88 | }
89 |
90 | .right-panel-list-item-message.success {
91 | color: #52c41a;
92 | }
93 |
94 | pre {
95 | background-color: #fafafa;
96 | padding: 10px;
97 | border-radius: 4px;
98 | white-space: pre-wrap;
99 | word-break: break-all;
100 | }
101 |
102 | .step-content-container {
103 | --max-height: calc(100vh - 80px);
104 | max-height: calc(var(--max-height) / 2);
105 | overflow-y: auto;
106 | }
--------------------------------------------------------------------------------
/frontend/src/services/api.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 |
3 | const api = axios.create({
4 | baseURL: '/api',
5 | timeout: 100000,
6 | headers: {
7 | 'Content-Type': 'application/json',
8 | },
9 | });
10 |
11 | // 添加请求拦截器,自动添加会话ID头部
12 | api.interceptors.request.use(
13 | (config) => {
14 | const sessionId = localStorage.getItem('session_id');
15 | if (sessionId) {
16 | config.headers['X-Session-ID'] = sessionId;
17 | }
18 | return config;
19 | },
20 | (error) => {
21 | return Promise.reject(error);
22 | }
23 | );
24 |
25 | // 测试代理
26 | export const testProxy = async (data: any) => {
27 | return api.post('/test_proxy', data);
28 | };
29 |
30 | // 初始化注册
31 | export const initialize = async (data: any) => {
32 | return api.post('/initialize', data, {
33 | headers: {
34 | 'Content-Type': 'multipart/form-data',
35 | },
36 | });
37 | };
38 |
39 | // 验证验证码
40 | export const verifyCaptha = async (data:any) => {
41 | return api.post('/verify_captcha',data, {
42 | headers: {
43 | 'Content-Type': 'multipart/form-data',
44 | },
45 | });
46 | };
47 |
48 | // 注册账号
49 | export const register = async (data: any) => {
50 | return api.post('/register', data, {
51 | headers: {
52 | 'Content-Type': 'multipart/form-data',
53 | },
54 | });
55 | };
56 |
57 | // 获取邮箱验证码
58 | export const getEmailVerificationCode = async (data: any) => {
59 | return api.post('/get_email_verification_code', data);
60 | };
61 |
62 | // 激活账号
63 | export const activateAccounts = async (key: string, names: string[], all: boolean=false) => {
64 | return api.post('/activate_account_with_names', { key, names, all });
65 | };
66 |
67 | // 顺序激活账号(带SSE支持)
68 | export const activateAccountsSequential = (key: string, names: string[], all: boolean=false, minDelay: number=10, maxDelay: number=30) => {
69 | const sessionId = localStorage.getItem('session_id') || '';
70 |
71 | // 直接发送POST请求并建立SSE连接
72 | const url = '/api/activate_account_sequential';
73 | const body = JSON.stringify({ key, names, all, delay_min: minDelay, delay_max: maxDelay });
74 |
75 | // 使用fetch发送POST请求并获取流式响应
76 | return fetch(url, {
77 | method: 'POST',
78 | headers: {
79 | 'Content-Type': 'application/json',
80 | 'X-Session-ID': sessionId,
81 | 'Accept': 'text/event-stream',
82 | 'Cache-Control': 'no-cache',
83 | },
84 | body: body,
85 | credentials: 'include',
86 | }).then(response => {
87 | if (!response.ok) {
88 | throw new Error(`HTTP error! status: ${response.status}`);
89 | }
90 |
91 | if (!response.body) {
92 | throw new Error('No response body');
93 | }
94 |
95 | // 创建自定义的EventSource-like对象来处理流式响应
96 | const reader = response.body.getReader();
97 | const decoder = new TextDecoder();
98 | let buffer = '';
99 |
100 | const eventSource = {
101 | onmessage: null as ((event: any) => void) | null,
102 | onerror: null as ((event: any) => void) | null,
103 | onopen: null as ((event: any) => void) | null,
104 | readyState: 1, // OPEN
105 |
106 | close: () => {
107 | reader.cancel();
108 | eventSource.readyState = 2; // CLOSED
109 | }
110 | };
111 |
112 | // 触发onopen事件
113 | if (eventSource.onopen) {
114 | eventSource.onopen({ type: 'open' });
115 | }
116 |
117 | // 读取流数据
118 | const readStream = async () => {
119 | try {
120 | while (true) {
121 | const { done, value } = await reader.read();
122 |
123 | if (done) {
124 | break;
125 | }
126 |
127 | buffer += decoder.decode(value, { stream: true });
128 |
129 | // 处理SSE格式的数据
130 | const lines = buffer.split('\n');
131 | buffer = lines.pop() || ''; // 保留不完整的行
132 |
133 | for (const line of lines) {
134 | if (line.startsWith('data: ')) {
135 | const data = line.slice(6); // 移除 'data: ' 前缀
136 | if (data.trim() && eventSource.onmessage) {
137 | eventSource.onmessage({ data, type: 'message' });
138 | }
139 | }
140 | }
141 | }
142 | } catch (error) {
143 | console.error('SSE读取错误:', error);
144 | if (eventSource.onerror) {
145 | eventSource.onerror({ error, type: 'error' });
146 | }
147 | }
148 | };
149 |
150 | // 开始读取流
151 | readStream();
152 |
153 | return eventSource;
154 | });
155 | };
156 |
157 | // 获取账号列表
158 | export const fetchAccounts = async () => {
159 | return api.get('/fetch_accounts');
160 | };
161 |
162 | // 删除账号
163 | export const deleteAccount = async (accountId: string) => {
164 | return api.post('/delete_account', { id: accountId });
165 | };
166 |
167 | // 批量删除账号
168 | export const deleteAccounts = async (accountIds: string[]) => {
169 | return api.post('/delete_account', { ids: accountIds });
170 | };
171 |
172 | // 更新账号
173 | export const updateAccount = async (id: string, accountData: any) => {
174 | return api.post('/update_account', {
175 | id,
176 | account_data: accountData,
177 | });
178 | };
179 |
180 | // 账号管理 - 获取VIP信息
181 | export const getAccountVipInfo = async (accountData: any) => {
182 | return api.post('/account/vip_info', {
183 | token: accountData.access_token || accountData.token,
184 | device_id: accountData.device_id,
185 | client_id: accountData.client_id,
186 | captcha_token: accountData.captcha_token
187 | });
188 | };
189 |
190 | // 账号管理 - 获取邀请码
191 | export const getAccountInviteCode = async (accountData: any) => {
192 | return api.post('/account/invite_code', {
193 | token: accountData.access_token || accountData.token,
194 | device_id: accountData.device_id,
195 | captcha_token: accountData.captcha_token
196 | });
197 | };
198 |
199 | // 账号管理 - 获取邀请记录
200 | export const getAccountInviteList = async (accountData: any, limit: number = 500) => {
201 | return api.post('/account/invite_list', {
202 | token: accountData.access_token || accountData.token,
203 | device_id: accountData.device_id,
204 | captcha_token: accountData.captcha_token,
205 | client_id: accountData.client_id || "YNxT9w7GMdWvEOKa",
206 | limit
207 | });
208 | };
209 |
210 | export default api;
--------------------------------------------------------------------------------
/frontend/src/utils/config.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Configuration utility for managing settings in localStorage
3 | */
4 |
5 | // Default configuration
6 | export interface AppConfig {
7 | savedInviteCode: string;
8 | useProxy: boolean;
9 | useProxyPool: boolean;
10 | useEmailProxy: boolean;
11 | }
12 |
13 | const DEFAULT_CONFIG: AppConfig = {
14 | savedInviteCode: '',
15 | useProxy: false,
16 | useProxyPool: false,
17 | useEmailProxy: false
18 | };
19 |
20 | const CONFIG_KEY = 'pikpak_config';
21 |
22 | /**
23 | * Load configuration from localStorage
24 | */
25 | export const loadConfig = (): AppConfig => {
26 | try {
27 | const savedConfig = localStorage.getItem(CONFIG_KEY);
28 | if (savedConfig) {
29 | return { ...DEFAULT_CONFIG, ...JSON.parse(savedConfig) };
30 | }
31 |
32 | // For backward compatibility: migrate old invite code if exists
33 | const savedInviteCode = localStorage.getItem('savedInviteCode');
34 | if (savedInviteCode) {
35 | const config = { ...DEFAULT_CONFIG, savedInviteCode };
36 | saveConfig(config);
37 | return config;
38 | }
39 | } catch (error) {
40 | console.error('Failed to load configuration from localStorage:', error);
41 | }
42 |
43 | return DEFAULT_CONFIG;
44 | };
45 |
46 | /**
47 | * Save configuration to localStorage
48 | */
49 | export const saveConfig = (config: Partial): void => {
50 | try {
51 | const currentConfig = loadConfig();
52 | const newConfig = { ...currentConfig, ...config };
53 | localStorage.setItem(CONFIG_KEY, JSON.stringify(newConfig));
54 | } catch (error) {
55 | console.error('Failed to save configuration to localStorage:', error);
56 | }
57 | };
58 |
59 | /**
60 | * Update a specific configuration value
61 | */
62 | export const updateConfig = (key: K, value: AppConfig[K]): void => {
63 | try {
64 | const config = loadConfig();
65 | config[key] = value;
66 | saveConfig(config);
67 | } catch (error) {
68 | console.error(`Failed to update configuration key ${key}:`, error);
69 | }
70 | };
71 |
72 | /**
73 | * Get a specific configuration value
74 | */
75 | export const getConfigValue = (key: K): AppConfig[K] => {
76 | const config = loadConfig();
77 | return config[key];
78 | };
--------------------------------------------------------------------------------
/frontend/src/utils/httpClient.ts:
--------------------------------------------------------------------------------
1 | // HTTP客户端工具,自动处理会话ID
2 | export class HttpClient {
3 | private static getSessionId(): string | null {
4 | return localStorage.getItem('session_id');
5 | }
6 |
7 | private static getHeaders(): HeadersInit {
8 | const headers: HeadersInit = {
9 | 'Content-Type': 'application/json',
10 | };
11 |
12 | const sessionId = this.getSessionId();
13 | if (sessionId) {
14 | headers['X-Session-ID'] = sessionId;
15 | }
16 |
17 | return headers;
18 | }
19 |
20 | static async get(url: string): Promise {
21 | return fetch(url, {
22 | method: 'GET',
23 | headers: this.getHeaders(),
24 | });
25 | }
26 |
27 | static async post(url: string, data?: any): Promise {
28 | return fetch(url, {
29 | method: 'POST',
30 | headers: this.getHeaders(),
31 | body: data ? JSON.stringify(data) : undefined,
32 | });
33 | }
34 |
35 | static async put(url: string, data?: any): Promise {
36 | return fetch(url, {
37 | method: 'PUT',
38 | headers: this.getHeaders(),
39 | body: data ? JSON.stringify(data) : undefined,
40 | });
41 | }
42 |
43 | static async delete(url: string, data?: any): Promise {
44 | return fetch(url, {
45 | method: 'DELETE',
46 | headers: this.getHeaders(),
47 | body: data ? JSON.stringify(data) : undefined,
48 | });
49 | }
50 |
51 | // 表单数据提交(用于兼容现有的表单提交)
52 | static async postForm(url: string, formData: FormData): Promise {
53 | const sessionId = this.getSessionId();
54 | const headers: HeadersInit = {};
55 |
56 | if (sessionId) {
57 | headers['X-Session-ID'] = sessionId;
58 | }
59 |
60 | return fetch(url, {
61 | method: 'POST',
62 | headers,
63 | body: formData,
64 | });
65 | }
66 | }
67 |
68 | export default HttpClient;
--------------------------------------------------------------------------------
/frontend/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/frontend/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "isolatedModules": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 |
18 | /* Linting */
19 | "strict": true,
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | "noFallthroughCasesInSwitch": true,
23 | "noUncheckedSideEffectImports": true
24 | },
25 | "include": ["src"]
26 | }
27 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" }
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/frontend/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "isolatedModules": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "noUncheckedSideEffectImports": true
22 | },
23 | "include": ["vite.config.ts"]
24 | }
25 |
--------------------------------------------------------------------------------
/frontend/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 |
4 | // https://vite.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | server: {
8 | port: 5678,
9 | proxy: {
10 | '/api': {
11 | target: 'http://localhost:5000',
12 | changeOrigin: true,
13 | }
14 | },
15 | },
16 | resolve: {
17 | alias: {
18 | '@': '/src',
19 | },
20 | },
21 | })
22 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/1653756334/PikPakInvitation/6e76029c8c940d56901986ca18ab74e12229d668/requirements.txt
--------------------------------------------------------------------------------
/utils/__pycache__/database.cpython-311.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/1653756334/PikPakInvitation/6e76029c8c940d56901986ca18ab74e12229d668/utils/__pycache__/database.cpython-311.pyc
--------------------------------------------------------------------------------
/utils/__pycache__/email_client.cpython-311.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/1653756334/PikPakInvitation/6e76029c8c940d56901986ca18ab74e12229d668/utils/__pycache__/email_client.cpython-311.pyc
--------------------------------------------------------------------------------
/utils/__pycache__/pikpak.cpython-311.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/1653756334/PikPakInvitation/6e76029c8c940d56901986ca18ab74e12229d668/utils/__pycache__/pikpak.cpython-311.pyc
--------------------------------------------------------------------------------
/utils/__pycache__/pk_email.cpython-311.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/1653756334/PikPakInvitation/6e76029c8c940d56901986ca18ab74e12229d668/utils/__pycache__/pk_email.cpython-311.pyc
--------------------------------------------------------------------------------
/utils/__pycache__/session_manager.cpython-311.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/1653756334/PikPakInvitation/6e76029c8c940d56901986ca18ab74e12229d668/utils/__pycache__/session_manager.cpython-311.pyc
--------------------------------------------------------------------------------
/utils/database.py:
--------------------------------------------------------------------------------
1 | import sqlite3
2 | import json
3 | import os
4 | import logging
5 | from typing import Dict, List, Optional, Any
6 | from datetime import datetime
7 | import threading
8 | import random
9 | import requests
10 |
11 | logger = logging.getLogger(__name__)
12 |
13 | class DatabaseManager:
14 | """数据库管理器,处理账号数据的存储和会话隔离"""
15 |
16 | def __init__(self, db_path: str = "accounts.db"):
17 | self.db_path = db_path
18 | self.lock = threading.Lock()
19 | self.init_database()
20 |
21 | def init_database(self):
22 | """初始化数据库表"""
23 | with self.lock:
24 | conn = sqlite3.connect(self.db_path)
25 | try:
26 | cursor = conn.cursor()
27 |
28 | # 创建账号表
29 | cursor.execute('''
30 | CREATE TABLE IF NOT EXISTS accounts (
31 | id INTEGER PRIMARY KEY AUTOINCREMENT,
32 | session_id TEXT NOT NULL,
33 | email TEXT NOT NULL,
34 | password TEXT,
35 | client_id TEXT,
36 | token TEXT,
37 | device_id TEXT,
38 | invite_code TEXT,
39 | activation_status INTEGER DEFAULT 0, -- 激活状态:0=未激活,1+=激活次数
40 | last_activation_time TIMESTAMP, -- 最后激活时间
41 | account_data TEXT, -- JSON格式存储完整账号信息
42 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
43 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
44 | UNIQUE(session_id, email)
45 | )
46 | ''')
47 |
48 | # 创建会话表
49 | cursor.execute('''
50 | CREATE TABLE IF NOT EXISTS sessions (
51 | session_id TEXT PRIMARY KEY,
52 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
53 | last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP
54 | )
55 | ''')
56 |
57 | # 创建代理池表
58 | cursor.execute('''
59 | CREATE TABLE IF NOT EXISTS proxy_pool (
60 | id INTEGER PRIMARY KEY AUTOINCREMENT,
61 | proxy_url TEXT NOT NULL UNIQUE,
62 | protocol TEXT NOT NULL, -- http, https, socks5
63 | host TEXT NOT NULL,
64 | port INTEGER NOT NULL,
65 | username TEXT,
66 | password TEXT,
67 | is_active BOOLEAN DEFAULT 1,
68 | last_checked TIMESTAMP,
69 | response_time REAL, -- 响应时间(秒)
70 | success_count INTEGER DEFAULT 0,
71 | fail_count INTEGER DEFAULT 0,
72 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
73 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
74 | )
75 | ''')
76 |
77 | # 检查并添加新字段(数据库升级)
78 | self._upgrade_database(cursor)
79 |
80 | # 创建索引
81 | cursor.execute('CREATE INDEX IF NOT EXISTS idx_session_id ON accounts(session_id)')
82 | cursor.execute('CREATE INDEX IF NOT EXISTS idx_email ON accounts(email)')
83 | cursor.execute('CREATE INDEX IF NOT EXISTS idx_activation_status ON accounts(activation_status)')
84 | cursor.execute('CREATE INDEX IF NOT EXISTS idx_proxy_active ON proxy_pool(is_active)')
85 | cursor.execute('CREATE INDEX IF NOT EXISTS idx_proxy_response_time ON proxy_pool(response_time)')
86 |
87 | conn.commit()
88 | logger.info("数据库初始化完成")
89 |
90 | except Exception as e:
91 | logger.error(f"数据库初始化失败: {e}")
92 | conn.rollback()
93 | finally:
94 | conn.close()
95 |
96 | def _upgrade_database(self, cursor):
97 | """数据库升级:为现有表添加新字段"""
98 | try:
99 | # 检查activation_status字段是否存在
100 | cursor.execute("PRAGMA table_info(accounts)")
101 | columns = [row[1] for row in cursor.fetchall()]
102 |
103 | if 'activation_status' not in columns:
104 | logger.info("添加activation_status字段...")
105 | cursor.execute('ALTER TABLE accounts ADD COLUMN activation_status INTEGER DEFAULT 0')
106 |
107 | if 'last_activation_time' not in columns:
108 | logger.info("添加last_activation_time字段...")
109 | cursor.execute('ALTER TABLE accounts ADD COLUMN last_activation_time TIMESTAMP')
110 |
111 | except Exception as e:
112 | logger.error(f"数据库升级失败: {e}")
113 |
114 | def create_session(self, session_id: str) -> bool:
115 | """创建新会话"""
116 | with self.lock:
117 | conn = sqlite3.connect(self.db_path)
118 | try:
119 | cursor = conn.cursor()
120 | cursor.execute('''
121 | INSERT OR REPLACE INTO sessions (session_id, last_active)
122 | VALUES (?, CURRENT_TIMESTAMP)
123 | ''', (session_id,))
124 | conn.commit()
125 | logger.info(f"会话 {session_id} 创建成功")
126 | return True
127 | except Exception as e:
128 | logger.error(f"创建会话失败: {e}")
129 | conn.rollback()
130 | return False
131 | finally:
132 | conn.close()
133 |
134 | def update_session_activity(self, session_id: str):
135 | """更新会话活跃时间"""
136 | with self.lock:
137 | conn = sqlite3.connect(self.db_path)
138 | try:
139 | cursor = conn.cursor()
140 | cursor.execute('''
141 | UPDATE sessions SET last_active = CURRENT_TIMESTAMP
142 | WHERE session_id = ?
143 | ''', (session_id,))
144 | conn.commit()
145 | except Exception as e:
146 | logger.error(f"更新会话活跃时间失败: {e}")
147 | finally:
148 | conn.close()
149 |
150 | def save_account(self, session_id: str, account_info: Dict[str, Any]) -> bool:
151 | """保存账号信息"""
152 | with self.lock:
153 | conn = sqlite3.connect(self.db_path)
154 | try:
155 | cursor = conn.cursor()
156 |
157 | # 直接在同一个连接中更新会话活跃时间,避免死锁
158 | cursor.execute('''
159 | UPDATE sessions SET last_active = CURRENT_TIMESTAMP
160 | WHERE session_id = ?
161 | ''', (session_id,))
162 |
163 | cursor.execute('''
164 | INSERT OR REPLACE INTO accounts
165 | (session_id, email, password, client_id, token, device_id, invite_code, account_data, updated_at)
166 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
167 | ''', (
168 | session_id,
169 | account_info.get('email', ''),
170 | account_info.get('password', ''),
171 | account_info.get('client_id', ''),
172 | account_info.get('access_token', account_info.get('token', '')), # 优先使用access_token
173 | account_info.get('device_id', ''),
174 | account_info.get('invite_code', ''),
175 | json.dumps(account_info, ensure_ascii=False)
176 | ))
177 |
178 | conn.commit()
179 | logger.info(f"账号 {account_info.get('email')} 保存成功 (会话: {session_id})")
180 | return True
181 |
182 | except Exception as e:
183 | logger.error(f"保存账号失败: {e}")
184 | conn.rollback()
185 | return False
186 | finally:
187 | conn.close()
188 |
189 | def get_accounts(self, session_id: str, is_admin: bool = False) -> List[Dict[str, Any]]:
190 | """获取账号列表"""
191 | with self.lock:
192 | conn = sqlite3.connect(self.db_path)
193 | try:
194 | cursor = conn.cursor()
195 |
196 | if is_admin:
197 | # 管理员可以看到所有账号
198 | cursor.execute('''
199 | SELECT id, session_id, email, account_data, activation_status, last_activation_time, created_at, updated_at
200 | FROM accounts
201 | ORDER BY updated_at DESC
202 | ''')
203 | else:
204 | # 普通用户只能看到自己的账号
205 | cursor.execute('''
206 | SELECT id, session_id, email, account_data, activation_status, last_activation_time, created_at, updated_at
207 | FROM accounts
208 | WHERE session_id = ?
209 | ORDER BY updated_at DESC
210 | ''', (session_id,))
211 |
212 | rows = cursor.fetchall()
213 |
214 | # 在查询之后更新会话活跃时间,避免影响查询结果
215 | if not is_admin:
216 | cursor.execute('''
217 | UPDATE sessions SET last_active = CURRENT_TIMESTAMP
218 | WHERE session_id = ?
219 | ''', (session_id,))
220 | conn.commit() # 提交更新
221 |
222 | accounts = []
223 | for row in rows:
224 | account_data = json.loads(row[3]) if row[3] else {}
225 | account_data.update({
226 | 'id': row[0],
227 | 'session_id': row[1],
228 | 'email': row[2],
229 | 'activation_status': row[4] if row[4] is not None else 0,
230 | 'last_activation_time': row[5],
231 | 'created_at': row[6],
232 | 'updated_at': row[7]
233 | })
234 | accounts.append(account_data)
235 |
236 | return accounts
237 |
238 | except Exception as e:
239 | logger.error(f"获取账号列表失败: {e}")
240 | return []
241 | finally:
242 | conn.close()
243 |
244 | def update_account(self, session_id: str, account_id: int, account_data: Dict[str, Any], is_admin: bool = False) -> bool:
245 | """更新账号信息"""
246 | with self.lock:
247 | conn = sqlite3.connect(self.db_path)
248 | try:
249 | cursor = conn.cursor()
250 |
251 | # 检查权限
252 | if not is_admin:
253 | cursor.execute('SELECT session_id FROM accounts WHERE id = ?', (account_id,))
254 | result = cursor.fetchone()
255 | if not result or result[0] != session_id:
256 | logger.warning(f"用户 {session_id} 尝试更新不属于自己的账号 {account_id}")
257 | return False
258 |
259 | cursor.execute('''
260 | UPDATE accounts
261 | SET email = ?, password = ?, client_id = ?, token = ?,
262 | device_id = ?, invite_code = ?, account_data = ?, updated_at = CURRENT_TIMESTAMP
263 | WHERE id = ?
264 | ''', (
265 | account_data.get('email', ''),
266 | account_data.get('password', ''),
267 | account_data.get('client_id', ''),
268 | account_data.get('access_token', account_data.get('token', '')), # 优先使用access_token
269 | account_data.get('device_id', ''),
270 | account_data.get('invite_code', ''),
271 | json.dumps(account_data, ensure_ascii=False),
272 | account_id
273 | ))
274 |
275 | if cursor.rowcount > 0:
276 | conn.commit()
277 | logger.info(f"账号 {account_id} 更新成功")
278 | return True
279 | else:
280 | logger.warning(f"账号 {account_id} 不存在")
281 | return False
282 |
283 | except Exception as e:
284 | logger.error(f"更新账号失败: {e}")
285 | conn.rollback()
286 | return False
287 | finally:
288 | conn.close()
289 |
290 | def delete_account(self, session_id: str, account_id: int, is_admin: bool = False) -> bool:
291 | """删除账号"""
292 | with self.lock:
293 | conn = sqlite3.connect(self.db_path)
294 | try:
295 | cursor = conn.cursor()
296 |
297 | # 检查权限
298 | if not is_admin:
299 | cursor.execute('SELECT session_id FROM accounts WHERE id = ?', (account_id,))
300 | result = cursor.fetchone()
301 | if not result or result[0] != session_id:
302 | logger.warning(f"用户 {session_id} 尝试删除不属于自己的账号 {account_id}")
303 | return False
304 |
305 | cursor.execute('DELETE FROM accounts WHERE id = ?', (account_id,))
306 |
307 | if cursor.rowcount > 0:
308 | conn.commit()
309 | logger.info(f"账号 {account_id} 删除成功")
310 | return True
311 | else:
312 | logger.warning(f"账号 {account_id} 不存在")
313 | return False
314 |
315 | except Exception as e:
316 | logger.error(f"删除账号失败: {e}")
317 | conn.rollback()
318 | return False
319 | finally:
320 | conn.close()
321 |
322 | def update_activation_status(self, session_id: str, account_id: int, is_admin: bool = False) -> bool:
323 | """更新账号激活状态(激活次数+1)"""
324 | with self.lock:
325 | conn = sqlite3.connect(self.db_path)
326 | try:
327 | cursor = conn.cursor()
328 |
329 | # 首先获取当前激活状态
330 | if is_admin:
331 | cursor.execute('SELECT activation_status FROM accounts WHERE id = ?', (account_id,))
332 | else:
333 | cursor.execute('SELECT activation_status FROM accounts WHERE id = ? AND session_id = ?', (account_id, session_id))
334 |
335 | result = cursor.fetchone()
336 | if not result:
337 | return False
338 |
339 | current_status = result[0] if result[0] is not None else 0
340 | new_status = current_status + 1
341 |
342 | # 更新激活状态和时间
343 | if is_admin:
344 | cursor.execute('''
345 | UPDATE accounts
346 | SET activation_status = ?,
347 | last_activation_time = CURRENT_TIMESTAMP,
348 | updated_at = CURRENT_TIMESTAMP
349 | WHERE id = ?
350 | ''', (new_status, account_id))
351 | else:
352 | cursor.execute('''
353 | UPDATE accounts
354 | SET activation_status = ?,
355 | last_activation_time = CURRENT_TIMESTAMP,
356 | updated_at = CURRENT_TIMESTAMP
357 | WHERE id = ? AND session_id = ?
358 | ''', (new_status, account_id, session_id))
359 |
360 | if cursor.rowcount > 0:
361 | conn.commit()
362 | logger.info(f"账号 {account_id} 激活状态更新为 {new_status}")
363 | return True
364 | else:
365 | return False
366 |
367 | except Exception as e:
368 | logger.error(f"更新激活状态失败: {e}")
369 | conn.rollback()
370 | return False
371 | finally:
372 | conn.close()
373 |
374 | def get_account_by_id(self, session_id: str, account_id: int, is_admin: bool = False) -> Optional[Dict[str, Any]]:
375 | """根据ID获取账号信息"""
376 | with self.lock:
377 | conn = sqlite3.connect(self.db_path)
378 | try:
379 | cursor = conn.cursor()
380 |
381 | if is_admin:
382 | cursor.execute('''
383 | SELECT id, session_id, email, account_data, created_at, updated_at
384 | FROM accounts WHERE id = ?
385 | ''', (account_id,))
386 | else:
387 | cursor.execute('''
388 | SELECT id, session_id, email, account_data, created_at, updated_at
389 | FROM accounts WHERE id = ? AND session_id = ?
390 | ''', (account_id, session_id))
391 |
392 | row = cursor.fetchone()
393 | if row:
394 | account_data = json.loads(row[3]) if row[3] else {}
395 | account_data.update({
396 | 'id': row[0],
397 | 'session_id': row[1],
398 | 'email': row[2],
399 | 'created_at': row[4],
400 | 'updated_at': row[5]
401 | })
402 | return account_data
403 | return None
404 |
405 | except Exception as e:
406 | logger.error(f"获取账号信息失败: {e}")
407 | return None
408 | finally:
409 | conn.close()
410 |
411 | def get_account_by_email(self, email: str) -> Optional[Dict[str, Any]]:
412 | """根据邮箱获取账号信息"""
413 | with self.lock:
414 | conn = sqlite3.connect(self.db_path)
415 | try:
416 | cursor = conn.cursor()
417 |
418 | cursor.execute('''
419 | SELECT id, session_id, email, account_data, activation_status, last_activation_time, created_at, updated_at
420 | FROM accounts WHERE email = ?
421 | ''', (email,))
422 |
423 | row = cursor.fetchone()
424 | if row:
425 | account_data = json.loads(row[3]) if row[3] else {}
426 | account_data.update({
427 | 'id': row[0],
428 | 'session_id': row[1],
429 | 'email': row[2],
430 | 'activation_status': row[4] if row[4] is not None else 0,
431 | 'last_activation_time': row[5],
432 | 'created_at': row[6],
433 | 'updated_at': row[7]
434 | })
435 | return account_data
436 | return None
437 |
438 | except Exception as e:
439 | logger.error(f"根据邮箱获取账号信息失败: {e}")
440 | return None
441 | finally:
442 | conn.close()
443 |
444 | def migrate_from_files(self, account_dir: str = "account") -> int:
445 | """从文件迁移数据到数据库"""
446 | if not os.path.exists(account_dir):
447 | return 0
448 |
449 | migrated_count = 0
450 | default_session = "migrated_data"
451 |
452 | for filename in os.listdir(account_dir):
453 | if filename.endswith('.json'):
454 | file_path = os.path.join(account_dir, filename)
455 | try:
456 | with open(file_path, 'r', encoding='utf-8') as f:
457 | account_data = json.load(f)
458 |
459 | if isinstance(account_data, dict) and 'email' in account_data:
460 | if self.save_account(default_session, account_data):
461 | migrated_count += 1
462 | logger.info(f"迁移账号文件: {filename}")
463 |
464 | except Exception as e:
465 | logger.error(f"迁移文件 {filename} 失败: {e}")
466 |
467 | logger.info(f"数据迁移完成,共迁移 {migrated_count} 个账号")
468 | return migrated_count
469 |
470 | # 代理池管理方法
471 | def parse_proxy_url(self, proxy_url: str) -> Optional[Dict[str, Any]]:
472 | """解析代理URL"""
473 | try:
474 | import urllib.parse
475 | parsed = urllib.parse.urlparse(proxy_url)
476 |
477 | if not parsed.scheme or not parsed.hostname or not parsed.port:
478 | return None
479 |
480 | return {
481 | 'protocol': parsed.scheme.lower(),
482 | 'host': parsed.hostname,
483 | 'port': parsed.port,
484 | 'username': parsed.username,
485 | 'password': parsed.password
486 | }
487 | except Exception as e:
488 | logger.error(f"解析代理URL失败: {e}")
489 | return None
490 |
491 | def add_proxy(self, proxy_url: str) -> bool:
492 | """添加代理到代理池"""
493 | proxy_info = self.parse_proxy_url(proxy_url)
494 | if not proxy_info:
495 | logger.error(f"无效的代理URL格式: {proxy_url}")
496 | return False
497 |
498 | with self.lock:
499 | conn = sqlite3.connect(self.db_path)
500 | try:
501 | cursor = conn.cursor()
502 | cursor.execute('''
503 | INSERT OR REPLACE INTO proxy_pool
504 | (proxy_url, protocol, host, port, username, password, updated_at)
505 | VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
506 | ''', (
507 | proxy_url,
508 | proxy_info['protocol'],
509 | proxy_info['host'],
510 | proxy_info['port'],
511 | proxy_info['username'],
512 | proxy_info['password']
513 | ))
514 |
515 | conn.commit()
516 | logger.info(f"代理 {proxy_url} 添加成功")
517 | return True
518 |
519 | except Exception as e:
520 | logger.error(f"添加代理失败: {e}")
521 | conn.rollback()
522 | return False
523 | finally:
524 | conn.close()
525 |
526 | def remove_proxy(self, proxy_id: int) -> bool:
527 | """删除代理"""
528 | with self.lock:
529 | conn = sqlite3.connect(self.db_path)
530 | try:
531 | cursor = conn.cursor()
532 | cursor.execute('DELETE FROM proxy_pool WHERE id = ?', (proxy_id,))
533 |
534 | if cursor.rowcount > 0:
535 | conn.commit()
536 | logger.info(f"代理 {proxy_id} 删除成功")
537 | return True
538 | else:
539 | logger.warning(f"代理 {proxy_id} 不存在")
540 | return False
541 |
542 | except Exception as e:
543 | logger.error(f"删除代理失败: {e}")
544 | conn.rollback()
545 | return False
546 | finally:
547 | conn.close()
548 |
549 | def get_proxy_list(self) -> List[Dict[str, Any]]:
550 | """获取代理列表"""
551 | with self.lock:
552 | conn = sqlite3.connect(self.db_path)
553 | try:
554 | cursor = conn.cursor()
555 | cursor.execute('''
556 | SELECT id, proxy_url, protocol, host, port, username, password,
557 | is_active, last_checked, response_time, success_count,
558 | fail_count, created_at, updated_at
559 | FROM proxy_pool
560 | ORDER BY is_active DESC, response_time ASC, success_count DESC
561 | ''')
562 |
563 | proxies = []
564 | for row in cursor.fetchall():
565 | proxies.append({
566 | 'id': row[0],
567 | 'proxy_url': row[1],
568 | 'protocol': row[2],
569 | 'host': row[3],
570 | 'port': row[4],
571 | 'username': row[5],
572 | 'password': row[6],
573 | 'is_active': bool(row[7]),
574 | 'last_checked': row[8],
575 | 'response_time': row[9],
576 | 'success_count': row[10],
577 | 'fail_count': row[11],
578 | 'created_at': row[12],
579 | 'updated_at': row[13]
580 | })
581 |
582 | return proxies
583 |
584 | except Exception as e:
585 | logger.error(f"获取代理列表失败: {e}")
586 | return []
587 | finally:
588 | conn.close()
589 |
590 | def test_proxy(self, proxy_url: str, test_url: str = "https://httpbin.org/ip", timeout: int = 10) -> Dict[str, Any]:
591 | """测试代理连接"""
592 | try:
593 | proxies = {
594 | "http": proxy_url,
595 | "https": proxy_url
596 | }
597 |
598 | start_time = datetime.now()
599 | response = requests.get(test_url, proxies=proxies, timeout=timeout)
600 | end_time = datetime.now()
601 |
602 | response_time = (end_time - start_time).total_seconds()
603 |
604 | if response.status_code == 200:
605 | return {
606 | 'success': True,
607 | 'response_time': response_time,
608 | 'status_code': response.status_code,
609 | 'response': response.json() if response.headers.get('content-type', '').startswith('application/json') else response.text[:200]
610 | }
611 | else:
612 | return {
613 | 'success': False,
614 | 'response_time': response_time,
615 | 'status_code': response.status_code,
616 | 'error': f"HTTP {response.status_code}"
617 | }
618 |
619 | except Exception as e:
620 | return {
621 | 'success': False,
622 | 'response_time': None,
623 | 'error': str(e)
624 | }
625 |
626 | def update_proxy_status(self, proxy_id: int, success: bool, response_time: Optional[float] = None):
627 | """更新代理状态"""
628 | with self.lock:
629 | conn = sqlite3.connect(self.db_path)
630 | try:
631 | cursor = conn.cursor()
632 |
633 | if success:
634 | cursor.execute('''
635 | UPDATE proxy_pool
636 | SET success_count = success_count + 1,
637 | last_checked = CURRENT_TIMESTAMP,
638 | response_time = ?,
639 | is_active = 1,
640 | updated_at = CURRENT_TIMESTAMP
641 | WHERE id = ?
642 | ''', (response_time, proxy_id))
643 | else:
644 | cursor.execute('''
645 | UPDATE proxy_pool
646 | SET fail_count = fail_count + 1,
647 | last_checked = CURRENT_TIMESTAMP,
648 | updated_at = CURRENT_TIMESTAMP
649 | WHERE id = ?
650 | ''', (proxy_id,))
651 |
652 | # 如果失败次数过多,标记为不活跃
653 | cursor.execute('''
654 | UPDATE proxy_pool
655 | SET is_active = 0
656 | WHERE id = ? AND fail_count >= 5
657 | ''', (proxy_id,))
658 |
659 | conn.commit()
660 |
661 | except Exception as e:
662 | logger.error(f"更新代理状态失败: {e}")
663 | conn.rollback()
664 | finally:
665 | conn.close()
666 |
667 | def get_random_proxy(self) -> Optional[str]:
668 | """从代理池中随机获取一个可用代理"""
669 | with self.lock:
670 | conn = sqlite3.connect(self.db_path)
671 | try:
672 | cursor = conn.cursor()
673 | cursor.execute('''
674 | SELECT id, proxy_url FROM proxy_pool
675 | WHERE is_active = 1
676 | ORDER BY response_time ASC, success_count DESC
677 | LIMIT 10
678 | ''')
679 |
680 | proxies = cursor.fetchall()
681 | if not proxies:
682 | return None
683 |
684 | # 随机选择一个代理
685 | selected_proxy = random.choice(proxies)
686 | proxy_id, proxy_url = selected_proxy
687 |
688 | logger.info(f"选择代理: {proxy_url}")
689 | return proxy_url
690 |
691 | except Exception as e:
692 | logger.error(f"获取随机代理失败: {e}")
693 | return None
694 | finally:
695 | conn.close()
696 |
697 | def batch_test_proxies(self) -> Dict[str, Any]:
698 | """批量测试所有代理"""
699 | proxies = self.get_proxy_list()
700 | results = {
701 | 'total': len(proxies),
702 | 'tested': 0,
703 | 'success': 0,
704 | 'failed': 0,
705 | 'details': []
706 | }
707 |
708 | for proxy in proxies:
709 | proxy_id = proxy['id']
710 | proxy_url = proxy['proxy_url']
711 |
712 | logger.info(f"测试代理: {proxy_url}")
713 | test_result = self.test_proxy(proxy_url)
714 |
715 | self.update_proxy_status(
716 | proxy_id,
717 | test_result['success'],
718 | test_result.get('response_time')
719 | )
720 |
721 | results['tested'] += 1
722 | if test_result['success']:
723 | results['success'] += 1
724 | else:
725 | results['failed'] += 1
726 |
727 | results['details'].append({
728 | 'id': proxy_id,
729 | 'proxy_url': proxy_url,
730 | 'success': test_result['success'],
731 | 'response_time': test_result.get('response_time'),
732 | 'error': test_result.get('error')
733 | })
734 |
735 | logger.info(f"代理测试完成: {results['success']}/{results['total']} 成功")
736 | return results
737 |
738 | # 全局数据库实例
739 | db_manager = DatabaseManager()
--------------------------------------------------------------------------------
/utils/email_client.py:
--------------------------------------------------------------------------------
1 | import requests
2 | import re
3 | import json
4 | import logging
5 | import os
6 | from typing import Dict, List, Optional, Any
7 | from dotenv import load_dotenv
8 |
9 | # 加载环境变量,强制覆盖已存在的环境变量
10 | load_dotenv(override=True)
11 |
12 | # 配置日志
13 | logging.basicConfig(
14 | level=logging.INFO,
15 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
16 | )
17 | logger = logging.getLogger('email_client')
18 |
19 | # 添加一条日志,显示加载的环境变量值(如果存在)
20 | mail_api_url = os.getenv('MAIL_POINT_API_URL', '')
21 | logger.info(f"加载的MAIL_POINT_API_URL环境变量值: {mail_api_url}")
22 |
23 | class EmailClient:
24 | """邮件客户端类,封装邮件API操作"""
25 |
26 | def __init__(self, api_base_url: Optional[str] = None, use_proxy: bool = False, proxy_url: Optional[str] = None):
27 | """
28 | 初始化邮件客户端
29 |
30 | Args:
31 | api_base_url: API基础URL,如不提供则从环境变量MAIL_POINT_API_URL读取
32 | use_proxy: 是否使用代理
33 | proxy_url: 代理服务器URL (例如 "http://127.0.0.1:7890")
34 | """
35 | if api_base_url is None:
36 | # 添加调试信息,查看API_URL是否正确加载
37 | api_base_url = os.getenv('MAIL_POINT_API_URL', '')
38 | logger.info(f"使用的MAIL_POINT_API_URL环境变量值: {api_base_url}")
39 |
40 | self.api_base_url = api_base_url.rstrip('/')
41 | self.session = requests.Session()
42 |
43 | # 初始化代理设置
44 | self.use_proxy = use_proxy
45 | self.proxy_url = proxy_url
46 |
47 | # 如果启用代理,设置代理
48 | if self.use_proxy and self.proxy_url:
49 | self.set_proxy(self.proxy_url)
50 |
51 | def set_proxy(self, proxy_url: str) -> None:
52 | """
53 | 设置代理服务器
54 |
55 | Args:
56 | proxy_url: 代理服务器URL (例如 "http://127.0.0.1:7890")
57 | """
58 | if not proxy_url:
59 | logger.warning("代理URL为空,不设置代理")
60 | return
61 |
62 | # 为会话设置代理
63 | self.proxy_url = proxy_url
64 | self.use_proxy = True
65 |
66 | # 设置代理,支持HTTP和HTTPS
67 | proxies = {
68 | "http": proxy_url,
69 | "https": proxy_url
70 | }
71 | self.session.proxies.update(proxies)
72 | logger.info(f"已设置代理: {proxy_url}")
73 |
74 | def _make_request(self, endpoint: str, method: str = "POST", **params) -> Dict[str, Any]:
75 | """
76 | 发送API请求
77 |
78 | Args:
79 | endpoint: API端点
80 | method: 请求方法,GET或POST
81 | **params: 请求参数
82 |
83 | Returns:
84 | API响应的JSON数据
85 | """
86 | url = f"{self.api_base_url}{endpoint}"
87 |
88 | try:
89 | if method.upper() == "GET":
90 | response = self.session.get(url, params=params)
91 | else: # POST
92 | response = self.session.post(url, json=params)
93 |
94 | response.raise_for_status()
95 | return response.json()
96 | except requests.RequestException as e:
97 | logger.error(f"API请求失败: {str(e)}")
98 | return {"error": str(e), "status": "failed"}
99 |
100 | def get_latest_email(self, refresh_token: str, client_id: str, email: str,
101 | mailbox: str = "INBOX", response_type: str = "json",
102 | password: Optional[str] = None) -> Dict[str, Any]:
103 | """
104 | 获取最新一封邮件
105 |
106 | Args:
107 | refresh_token: 刷新令牌
108 | client_id: 客户端ID
109 | email: 邮箱地址
110 | mailbox: 邮箱文件夹,INBOX或Junk
111 | response_type: 返回格式,json或html
112 | password: 可选密码
113 |
114 | Returns:
115 | 包含最新邮件信息的字典
116 | """
117 | params = {
118 | 'refresh_token': refresh_token,
119 | 'client_id': client_id,
120 | 'email': email,
121 | 'mailbox': mailbox,
122 | 'response_type': response_type
123 | }
124 |
125 | if password:
126 | params['password'] = password
127 |
128 | return self._make_request('/api/mail-new', **params)
129 |
130 | def get_all_emails(self, refresh_token: str, client_id: str, email: str,
131 | mailbox: str = "INBOX", password: Optional[str] = None) -> Dict[str, Any]:
132 | """
133 | 获取全部邮件
134 |
135 | Args:
136 | refresh_token: 刷新令牌
137 | client_id: 客户端ID
138 | email: 邮箱地址
139 | mailbox: 邮箱文件夹,INBOX或Junk
140 | password: 可选密码
141 |
142 | Returns:
143 | 包含所有邮件信息的字典
144 | """
145 | params = {
146 | 'refresh_token': refresh_token,
147 | 'client_id': client_id,
148 | 'email': email,
149 | 'mailbox': mailbox
150 | }
151 |
152 | if password:
153 | params['password'] = password
154 |
155 | return self._make_request('/api/mail-all', **params)
156 |
157 | def process_inbox(self, refresh_token: str, client_id: str, email: str,
158 | password: Optional[str] = None) -> Dict[str, Any]:
159 | """
160 | 清空收件箱
161 |
162 | Args:
163 | refresh_token: 刷新令牌
164 | client_id: 客户端ID
165 | email: 邮箱地址
166 | password: 可选密码
167 |
168 | Returns:
169 | 操作结果字典
170 | """
171 | params = {
172 | 'refresh_token': refresh_token,
173 | 'client_id': client_id,
174 | 'email': email
175 | }
176 |
177 | if password:
178 | params['password'] = password
179 |
180 | return self._make_request('/api/process-inbox', **params)
181 |
182 | def process_junk(self, refresh_token: str, client_id: str, email: str,
183 | password: Optional[str] = None) -> Dict[str, Any]:
184 | """
185 | 清空垃圾箱
186 |
187 | Args:
188 | refresh_token: 刷新令牌
189 | client_id: 客户端ID
190 | email: 邮箱地址
191 | password: 可选密码
192 |
193 | Returns:
194 | 操作结果字典
195 | """
196 | params = {
197 | 'refresh_token': refresh_token,
198 | 'client_id': client_id,
199 | 'email': email
200 | }
201 |
202 | if password:
203 | params['password'] = password
204 |
205 | return self._make_request('/api/process-junk', **params)
206 |
207 | def send_email(self, refresh_token: str, client_id: str, email: str, to: str,
208 | subject: str, text: Optional[str] = None, html: Optional[str] = None,
209 | send_password: Optional[str] = None) -> Dict[str, Any]:
210 | """
211 | 发送邮件
212 |
213 | Args:
214 | refresh_token: 刷新令牌
215 | client_id: 客户端ID
216 | email: 发件人邮箱地址
217 | to: 收件人邮箱地址
218 | subject: 邮件主题
219 | text: 邮件的纯文本内容(与html二选一)
220 | html: 邮件的HTML内容(与text二选一)
221 | send_password: 可选发送密码
222 |
223 | Returns:
224 | 操作结果字典
225 | """
226 | if not text and not html:
227 | raise ValueError("必须提供text或html参数")
228 |
229 | params = {
230 | 'refresh_token': refresh_token,
231 | 'client_id': client_id,
232 | 'email': email,
233 | 'to': to,
234 | 'subject': subject
235 | }
236 |
237 | if text:
238 | params['text'] = text
239 | if html:
240 | params['html'] = html
241 | if send_password:
242 | params['send_password'] = send_password
243 |
244 | return self._make_request('/api/send-mail', **params)
245 |
246 | def get_verification_code(self, token: str, client_id: str, email: str,
247 | password: Optional[str] = None, mailbox: str = "INBOX",
248 | code_regex: str = r'\\b\\d{6}\\b') -> Optional[str]:
249 | """
250 | 获取最新邮件中的验证码
251 |
252 | Args:
253 | token: 刷新令牌 (对应API的refresh_token)
254 | client_id: 客户端ID
255 | email: 邮箱地址
256 | password: 可选密码
257 | mailbox: 邮箱文件夹,INBOX或Junk (默认为INBOX)
258 | code_regex: 用于匹配验证码的正则表达式 (默认为匹配6位数字)
259 |
260 | Returns:
261 | 找到的验证码字符串,如果未找到或出错则返回None
262 | """
263 | logger.info(f"尝试从邮箱 {email} 的 {mailbox} 获取验证码")
264 |
265 | # 调用 get_latest_email 获取邮件内容, 先从INBOX获取
266 | latest_email_data = self.get_latest_email(
267 | refresh_token=token,
268 | client_id=client_id,
269 | email=email,
270 | mailbox="INBOX",
271 | response_type='json', # 需要JSON格式来解析内容
272 | password=password
273 | )
274 |
275 | if not latest_email_data or (latest_email_data.get('send') is not None and isinstance(latest_email_data.get('send'), str) and 'PikPak' not in latest_email_data.get('send')):
276 | logger.error(f"在 INBOX 获取邮箱 {email} 最新邮件失败,尝试从Junk获取")
277 | latest_email_data = self.get_latest_email(
278 | refresh_token=token,
279 | client_id=client_id,
280 | email=email,
281 | mailbox="Junk",
282 | )
283 |
284 | logger.info(f"Junk latest_email_data: {latest_email_data.get('send')}")
285 | if not latest_email_data or (latest_email_data.get('send') is not None and isinstance(latest_email_data.get('send'), str) and 'PikPak' not in latest_email_data.get('send')):
286 | logger.error(f"在 Junk 获取邮箱 {email} 最新邮件失败")
287 | return None
288 |
289 | # 假设邮件正文在 'text' 或 'body' 字段
290 | email_content = latest_email_data.get('text') or latest_email_data.get('body')
291 |
292 | if not email_content:
293 | logger.warning(f"邮箱 {email} 的最新邮件数据中未找到 'text' 或 'body' 字段")
294 | return None
295 |
296 | # 使用正则表达式搜索验证码
297 | try:
298 | match = re.search(code_regex, email_content)
299 | if match:
300 | verification_code = match.group(0) # 通常验证码是整个匹配项
301 | logger.info(f"在邮箱 {email} 的邮件中成功找到验证码: {verification_code}")
302 | return verification_code
303 | else:
304 | logger.info(f"在邮箱 {email} 的最新邮件中未找到符合模式 {code_regex} 的验证码")
305 | return None
306 | except re.error as e:
307 | logger.error(f"提供的正则表达式 '{code_regex}' 无效: {e}")
308 | return None
309 | except Exception as e:
310 | logger.error(f"解析邮件内容或匹配验证码时发生未知错误: {e}")
311 | return None
312 |
313 | def parse_email_credentials(credentials_str: str) -> List[Dict[str, str]]:
314 | """
315 | 解析邮箱凭证字符串,提取邮箱、密码、Client ID和Token
316 |
317 | Args:
318 | credentials_str: 包含凭证信息的字符串
319 |
320 | Returns:
321 | 凭证列表,每个凭证为一个字典
322 | """
323 | credentials_list = []
324 | pattern = r'(.+?)----(.+?)----(.+?)----(.+?)(?:\n|$)'
325 | matches = re.finditer(pattern, credentials_str.strip())
326 |
327 | for match in matches:
328 | if len(match.groups()) == 4:
329 | email, password, client_id, token = match.groups()
330 | credentials_list.append({
331 | 'email': email.strip(),
332 | 'password': password.strip(),
333 | 'client_id': client_id.strip(),
334 | 'token': token.strip()
335 | })
336 |
337 | return credentials_list
338 |
339 | def load_credentials_from_file(file_path: str) -> str:
340 | """
341 | 从文件加载凭证信息
342 |
343 | Args:
344 | file_path: 文件路径
345 |
346 | Returns:
347 | 包含凭证的字符串
348 | """
349 | try:
350 | with open(file_path, 'r', encoding='utf-8') as file:
351 | content = file.read()
352 | # 提取多行字符串v的内容
353 | match = re.search(r'v\s*=\s*"""(.*?)"""', content, re.DOTALL)
354 | if match:
355 | return match.group(1)
356 | return ""
357 | except Exception as e:
358 | logger.error(f"加载凭证文件失败: {str(e)}")
359 | return ""
360 |
361 | def format_json_output(json_data: Dict) -> str:
362 | """
363 | 格式化JSON输出
364 |
365 | Args:
366 | json_data: JSON数据
367 |
368 | Returns:
369 | 格式化后的字符串
370 | """
371 | return json.dumps(json_data, ensure_ascii=False, indent=2)
--------------------------------------------------------------------------------
/utils/pk_email.py:
--------------------------------------------------------------------------------
1 | import imaplib
2 | import re
3 | import email
4 | import socket
5 | import socks # 增加 socks 库支持
6 |
7 | # IMAP 服务器信息
8 | IMAP_SERVER = 'imap.shanyouxiang.com'
9 | IMAP_PORT = 993 # IMAP SSL 端口
10 |
11 | # 邮件发送者列表(用于查找验证码)
12 | VERIFICATION_SENDERS = ['noreply@accounts.mypikpak.com']
13 |
14 |
15 | # --------------------------- IMAP 获取验证码 ---------------------------
16 |
17 | def connect_imap(email_user, email_password, folder='INBOX', use_proxy=False, proxy_url=None):
18 | """
19 | 使用 IMAP 连接并检查指定文件夹中的验证码邮件
20 | 支持通过代理连接
21 |
22 | 参数:
23 | email_user: 邮箱用户名
24 | email_password: 邮箱密码
25 | folder: 要检查的文件夹
26 | use_proxy: 是否使用代理
27 | proxy_url: 代理服务器URL (例如 "http://127.0.0.1:7890")
28 | """
29 | original_socket = None
30 |
31 | try:
32 | # 如果启用代理,设置SOCKS代理
33 | if use_proxy and proxy_url:
34 | # 解析代理URL
35 | if proxy_url.startswith(('http://', 'https://')):
36 | # 从HTTP代理URL提取主机和端口
37 | from urllib.parse import urlparse
38 | parsed = urlparse(proxy_url)
39 | proxy_host = parsed.hostname
40 | proxy_port = parsed.port or 80
41 |
42 | # 保存原始socket
43 | original_socket = socket.socket
44 |
45 | # 设置socks代理
46 | socks.set_default_proxy(socks.PROXY_TYPE_HTTP, proxy_host, proxy_port)
47 | socket.socket = socks.socksocket
48 |
49 | print(f"使用代理连接IMAP服务器: {proxy_url}")
50 |
51 | # 连接 IMAP 服务器
52 | mail = imaplib.IMAP4_SSL(IMAP_SERVER, IMAP_PORT)
53 | mail.login(email_user, email_password) # 直接使用邮箱密码登录
54 |
55 | # 选择文件夹
56 | status, _ = mail.select(folder)
57 | if status != 'OK':
58 | return {"code": 0, "msg": f"无法访问 {folder} 文件夹"}
59 |
60 | # 搜索邮件
61 | status, messages = mail.search(None, 'ALL')
62 | if status != 'OK' or not messages[0]:
63 | return {"code": 0, "msg": f"{folder} 文件夹为空"}
64 |
65 | message_ids = messages[0].split()
66 | verification_code = None
67 | timestamp = None
68 |
69 | for msg_id in message_ids[::-1]: # 从最新邮件开始查找
70 | status, msg_data = mail.fetch(msg_id, '(RFC822)')
71 | if status != 'OK':
72 | continue
73 |
74 | for response_part in msg_data:
75 | if isinstance(response_part, tuple):
76 | msg = email.message_from_bytes(response_part[1])
77 | from_email = msg['From']
78 |
79 | if any(sender in from_email for sender in VERIFICATION_SENDERS):
80 | timestamp = msg['Date']
81 |
82 | # 解析邮件正文
83 | if msg.is_multipart():
84 | for part in msg.walk():
85 | if part.get_content_type() == 'text/html':
86 | body = part.get_payload(decode=True).decode('utf-8')
87 | break
88 | else:
89 | body = msg.get_payload(decode=True).decode('utf-8')
90 |
91 | # 提取验证码
92 | match = re.search(r'\b(\d{6})\b', body)
93 | if match:
94 | verification_code = match.group(1)
95 | break
96 |
97 | if verification_code:
98 | break
99 |
100 | mail.logout()
101 |
102 | if verification_code:
103 | return {"code": 200, "verification_code": verification_code, "time": timestamp,
104 | "msg": f"成功获取验证码 ({folder})"}
105 | else:
106 | return {"code": 0, "msg": f"{folder} 中未找到验证码"}
107 |
108 | except imaplib.IMAP4.error as e:
109 | return {"code": 401, "msg": "IMAP 认证失败,请检查邮箱和密码是否正确,或者邮箱是否支持IMAP登录"}
110 | except Exception as e:
111 | return {"code": 500, "msg": f"错误: {str(e)}"}
112 | finally:
113 | # 恢复原始socket
114 | if original_socket:
115 | socket.socket = original_socket
116 |
117 |
--------------------------------------------------------------------------------
/utils/session_manager.py:
--------------------------------------------------------------------------------
1 | import os
2 | import random
3 | import string
4 | import logging
5 | from typing import Optional
6 | from dotenv import load_dotenv
7 |
8 | # 加载环境变量
9 | load_dotenv()
10 |
11 | logger = logging.getLogger(__name__)
12 |
13 | class SessionManager:
14 | """会话管理器"""
15 |
16 | def __init__(self):
17 | self.admin_session_id = os.getenv('ADMIN_SESSION_ID', 'admin123456')
18 | logger.info(f"管理员会话ID已加载: {self.admin_session_id}")
19 |
20 | def generate_session_id(self, length: int = 12) -> str:
21 | """
22 | 生成随机会话ID
23 |
24 | Args:
25 | length: 会话ID长度,默认12位,范围6-20位
26 |
27 | Returns:
28 | 生成的会话ID
29 | """
30 | if length < 6:
31 | length = 6
32 | elif length > 20:
33 | length = 20
34 |
35 | # 使用字母和数字生成随机字符串
36 | characters = string.ascii_letters + string.digits
37 | session_id = ''.join(random.choice(characters) for _ in range(length))
38 |
39 | logger.info(f"生成新会话ID: {session_id}")
40 | return session_id
41 |
42 | def is_valid_session_id(self, session_id: str) -> bool:
43 | """
44 | 验证会话ID格式是否有效
45 |
46 | Args:
47 | session_id: 要验证的会话ID
48 |
49 | Returns:
50 | 是否有效
51 | """
52 | if not session_id or not isinstance(session_id, str):
53 | return False
54 |
55 | # 检查长度
56 | if len(session_id) < 6 or len(session_id) > 20:
57 | return False
58 |
59 | # 检查字符是否为字母或数字
60 | if not session_id.isalnum():
61 | return False
62 |
63 | return True
64 |
65 | def is_admin(self, session_id: str) -> bool:
66 | """
67 | 检查是否为管理员会话
68 |
69 | Args:
70 | session_id: 会话ID
71 |
72 | Returns:
73 | 是否为管理员
74 | """
75 | return session_id == self.admin_session_id
76 |
77 | def get_admin_session_id(self) -> str:
78 | """获取管理员会话ID"""
79 | return self.admin_session_id
80 |
81 | # 全局会话管理器实例
82 | session_manager = SessionManager()
--------------------------------------------------------------------------------