feat: README, config.example, turnstile integration, exchange rate, auth utils refactor

This commit is contained in:
Ember Moth
2026-07-05 21:15:17 +08:00
parent 25d190b90e
commit 4ffdc89421
19 changed files with 763 additions and 4 deletions
Generated
+2
View File
@@ -3172,6 +3172,7 @@ dependencies = [
"tracing-appender", "tracing-appender",
"tracing-opentelemetry", "tracing-opentelemetry",
"tracing-subscriber", "tracing-subscriber",
"turnstile",
"urlencoding", "urlencoding",
"uuid 1.23.4", "uuid 1.23.4",
] ]
@@ -4999,6 +5000,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
"tracing",
"uuid 1.23.4", "uuid 1.23.4",
] ]
+1
View File
@@ -44,4 +44,5 @@ gtmpl = "0.7"
gtmpl_value = "0.5" gtmpl_value = "0.5"
sms = { path = "crates/sms" } sms = { path = "crates/sms" }
email = { path = "crates/email" } email = { path = "crates/email" }
turnstile = { path = "crates/turnstile" }
reqwest = { version = "0.12", features = ["json"] } reqwest = { version = "0.12", features = ["json"] }
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+247
View File
@@ -0,0 +1,247 @@
# PPanel Backend (Rust)
<div align="center">
[![License](https://img.shields.io/github/license/perfect-panel/ppanel-backend)](LICENSE)
[![Rust](https://img.shields.io/badge/Rust-2021_edition-orange)](https://www.rust-lang.org/)
[![Axum](https://img.shields.io/badge/Axum-0.8-blue)](https://github.com/tokio-rs/axum)
[![sqlx](https://img.shields.io/badge/sqlx-0.9-green)](https://github.com/launchbadge/sqlx)
**PPanel 服务端的 Rust 重写版本 — 基于 Axum + sqlx,同时支持 MySQL / MariaDB 和 PostgreSQL。**
[中文](README.md) | [报告问题](https://github.com/perfect-panel/ppanel-backend/issues/new)
</div>
> **第一条**
> 人人生而自由,在尊严与权利上一律平等。
> 他们赋有理性与良知,应当以兄弟般的精神彼此相待。
>
> **第十九条**
> 人人有思想与表达的自由;此项自由包括持有主张而不受干预,以及通过任何媒介、无论国界,自由寻求、接受和传播信息与思想。
>
> *来源:[United Nations Universal Declaration of Human Rights](https://www.un.org/sites/un2.un.org/files/2021/03/udhr.pdf)*
---
## 📋 概述
`ppanel-backend` 是 [PPanel 服务端 (Go)](https://github.com/perfect-panel/server) 的 Rust 移植版本,使用 **Axum 0.8 + sqlx 0.9 + Tokio** 构建,与 Go 版本保持完整的 API 兼容性。
### 与 Go 版本的差异与改进
| 维度 | Go 版 | Rust 版 |
|---|---|---|
| HTTP 框架 | go-zero / Hertz | Axum 0.8 |
| ORM | GORM | sqlx 0.9(编译期 SQL 检查) |
| 数据库 | MySQL 为主 | MySQL / MariaDB + PostgreSQL 双后端 |
| 异步运行时 | goroutine | Tokio |
| 内存安全 | GC | 所有权系统,无 GC 暂停 |
| 插件系统 | WebAssembly | 暂不实现 |
### 核心特性
- **多协议支持**Shadowsocks、V2Ray、VLESS、Trojan、Hysteria2、TUIC 等
- **双数据库后端**MySQL / MariaDB 和 PostgreSQL 共用一套代码,运行时自动检测方言
- **完整 API 兼容**:与 Go 版本接口一一对应,可无缝替换
- **OpenTelemetry 可观测性**stdout / OTLP gRPC / OTLP HTTP 链路追踪
- **Redis 限流**:验证码发送间隔(60 秒)+ 每日上限(15 次)
- **Cloudflare Turnstile**:登录 / 注册 / 重置密码三处人机验证
- **异步任务队列**:基于 asynq(Redis),兼容 Go 版任务类型键名
- **定时调度器**:订阅检查(60s)、流量重置(00:30)、流量统计(00:00)、汇率更新(01:00)
---
## 🚀 快速开始
### 前提条件
- **Rust** 1.75+(推荐使用 `rustup` 安装)
- **MySQL 8.0+ / MariaDB 10.6+** 或 **PostgreSQL 14+**
- **Redis** 6.0+
- **Git**
### 从源码运行
```bash
# 1. 克隆仓库
git clone https://github.com/perfect-panel/ppanel-backend.git
cd ppanel-backend
# 2. 复制并编辑配置
cp config.example.yaml config.yaml
# 编辑 config.yaml,填写数据库、Redis、JWT 等配置
# 3. 编译并运行
cargo run --release
```
服务默认监听 `0.0.0.0:8080``/health` 路由返回 `ok`
### 配置文件
配置文件路径默认为 `config.yaml`,可通过环境变量 `PPANEL_CONFIG` 覆盖:
```bash
PPANEL_CONFIG=/etc/ppanel/config.yaml cargo run --release
```
最小配置示例(MySQL):
```yaml
Host: 0.0.0.0
Port: 8080
Model: prod # prod | devdev 模式跳过 Turnstile 验证)
JwtAuth:
AccessSecret: "your-secret-here"
AccessExpire: 86400
Database:
Driver: mysql
Addr: "127.0.0.1:3306"
Username: ppanel
Password: your-password
DBName: ppanel
Redis:
Host: "127.0.0.1:6379"
Administrator:
Email: admin@example.com
Password: changeme
```
PostgreSQL 只需把 `Driver` 改为 `postgres``Addr` 改为 PG 格式即可。
---
## 🗄 数据库迁移
启动时自动执行迁移,无需额外命令。
迁移文件位于:
- `migrations/mysql/` — MySQL / MariaDB
- `migrations/postgres/` — PostgreSQL
每个版本同时提供 `*.sql`(正向)和 `*.down.sql`(回滚)。
### 从 MySQL 迁移到 PostgreSQL
使用内置工具:
```bash
cargo build --release -p mysql2postgres
./target/release/mysql2postgres \
--mysql "user:pass@tcp(127.0.0.1:3306)/ppanel" \
--postgres "postgres://user:pass@127.0.0.1/ppanel" \
--dry-run # 先预览,确认无误后去掉此参数
```
---
## 📁 目录结构
```
ppanel-backend/
├── crates/ # 独立库 crate
│ ├── email/ # 邮件发送
│ ├── ip/ # IP 地理位置
│ ├── jwt/ # JWT 签发 / 验证
│ ├── oauth/ # OAuth2Google / Apple / Telegram
│ ├── password/ # 密码哈希(PBKDF2 / bcrypt / MD5 / SHA-256
│ ├── payment/ # 支付平台(Alipay / ePay / Stripe
│ ├── result/ # HTTP 统一响应格式与错误码
│ ├── sms/ # 短信(阿里云 / Twilio / SmsBao / AboSend
│ └── turnstile/ # Cloudflare Turnstile 验证
├── migrations/ # 数据库迁移 SQL
│ ├── mysql/
│ └── postgres/
├── src/
│ ├── adapter/ # 代理协议适配器(生成订阅链接)
│ ├── config/ # 配置结构体
│ ├── handler/ # HTTP 路由与处理器
│ │ ├── admin/ # 管理员 API
│ │ ├── auth/ # 认证 API
│ │ ├── common/ # 公共 API
│ │ ├── notify/ # 支付回调
│ │ ├── public/ # 用户 API
│ │ └── server/ # 节点 API
│ ├── middleware/ # HTTP 中间件
│ ├── model/ # 数据模型(entity + DTO
│ ├── queue/ # 异步任务队列
│ ├── repository/ # 数据访问层(MySQL + PG 双实现)
│ ├── scheduler/ # 定时任务
│ ├── service/ # 业务逻辑
│ │ ├── admin/
│ │ ├── auth/
│ │ ├── common/
│ │ ├── public/
│ │ ├── server/
│ │ └── telegram/
│ ├── exchange_rate.rs # 汇率换算工具
│ ├── tracing_otel.rs # OpenTelemetry 初始化
│ └── main.rs
├── tools/
│ └── mysql2postgres/ # MySQL → PostgreSQL 数据迁移工具
├── Cargo.toml
└── config.example.yaml
```
---
## 🔗 API 兼容性
本版本与 Go 服务端 API 完全兼容,可直接对接:
- **前端**[PPanel Web](https://github.com/perfect-panel/frontend)
- **用户界面预览**[user.ppanel.dev](https://user.ppanel.dev)
- **管理界面预览**[admin.ppanel.dev](https://admin.ppanel.dev)
- **Swagger 文档**[ppanel.dev/zh-CN/swagger/ppanel](https://ppanel.dev/zh-CN/swagger/ppanel)
---
## 🧪 开发
### 运行测试
```bash
cargo test
```
### 代码检查
```bash
cargo check
cargo clippy
```
### 构建发布版
```bash
cargo build --release
# 产物:./target/release/ppanel-backend
```
### OpenTelemetry 链路追踪
在配置文件中添加:
```yaml
Trace:
Name: ppanel
Batcher: stdout # stdout | otlpgrpc | otlphttp
Endpoint: "" # 留空 + stdout 时输出到控制台
Sampler: 1.0
Disabled: false
```
---
## 🤝 贡献
欢迎 PR 和 Issue。移植工作参考 Go 原版 (`../server`)Rust 实现遵循 `AGENTS.md` 中记录的约定。
## 📄 许可证
本项目采用 [Apache License 2.0](LICENSE) 授权。
+148
View File
@@ -0,0 +1,148 @@
# PPanel Backend — 示例配置
# 复制为 config.yaml 并按实际环境修改。
# 路径可通过环境变量 PPANEL_CONFIG 覆盖。
Host: 0.0.0.0
Port: 8080
# prod | dev dev 模式跳过 Turnstile 验证,不建议生产使用)
Model: prod
Debug: false
JwtAuth:
AccessSecret: "change-me-to-a-random-secret"
AccessExpire: 86400 # token 有效期(秒)
# ── 数据库 ──────────────────────────────────────────────────────────────
# 支持 mysql(含 MariaDB)和 postgres,运行时自动检测方言。
Database:
Driver: mysql # mysql | postgres
Addr: "127.0.0.1:3306" # MySQL: host:port / PG: host:port
Username: ppanel
Password: your-db-password
DBName: ppanel
# 以下为可选调优
MaxIdleConns: 10
MaxOpenConns: 100
# ── Redis ───────────────────────────────────────────────────────────────
Redis:
Host: "127.0.0.1:6379"
Pass: ""
DB: 0
# ── 管理员初始账号(首次启动自动创建)─────────────────────────────────
Administrator:
Email: admin@example.com
Password: changeme
# ── 日志 ────────────────────────────────────────────────────────────────
Logger:
ServiceName: ppanel-backend
Mode: console # console | file | volume
Encoding: plain # plain | json
Level: info
Path: logs
# KeepDays: 7
# MaxBackups: 30
# ── 链路追踪(OpenTelemetry,可选)──────────────────────────────────────
Trace:
Name: ppanel
# stdout | otlpgrpc | otlphttp(留空或 Disabled:true 则禁用)
Batcher: stdout
Endpoint: ""
Sampler: 1.0
Disabled: true
# ── 站点 ────────────────────────────────────────────────────────────────
Site:
Host: "https://your-domain.com"
SiteName: PPanel
SiteDesc: ""
SiteLogo: ""
Keywords: ""
# ── JWT 验证 ─────────────────────────────────────────────────────────────
Verify:
TurnstileSiteKey: ""
TurnstileSecret: ""
LoginVerify: false
RegisterVerify: false
ResetPasswordVerify: false
VerifyCode:
ExpireTime: 300
Limit: 3
Interval: 60
# ── 注册 ─────────────────────────────────────────────────────────────────
Register:
StopRegister: false
EnableIpRegisterLimit: false
IpRegisterLimit: 5
IpRegisterLimitDuration: 3600
# ── 订阅 ─────────────────────────────────────────────────────────────────
Subscribe:
SingleModel: false
SubscribePath: /sub
SubscribeDomain: ""
PanDomain: false
UserAgentLimit: false
UserAgentList: ""
ShowTutorial: true
# ── 邮件 ─────────────────────────────────────────────────────────────────
Email:
Enable: true
Platform: smtp
PlatformConfig: '{"host":"smtp.example.com","port":465,"user":"no-reply@example.com","pass":"smtp-password","from":"PPanel <no-reply@example.com>"}'
EnableVerify: true
EnableNotify: true
EnableDomainSuffix: false
DomainSuffixList: ""
# ── 短信(可选)──────────────────────────────────────────────────────────
Mobile:
Enable: false
Platform: ""
PlatformConfig: ""
EnableVerify: false
EnableWhitelist: false
# ── 设备 ─────────────────────────────────────────────────────────────────
Device:
Enable: false
ShowAds: false
EnableSecurity: false
OnlyRealDevice: false
SecuritySecret: ""
# ── 节点 ─────────────────────────────────────────────────────────────────
Node:
NodeSecret: "change-me-node-secret"
NodePullInterval: 60
NodePushInterval: 60
TrafficReportThreshold: 0
IpStrategy: ""
# ── 邀请 ─────────────────────────────────────────────────────────────────
Invite:
ForcedInvite: false
ReferralPercentage: 10
OnlyFirstPurchase: false
# ── 货币 ─────────────────────────────────────────────────────────────────
Currency:
Unit: CNY
Symbol: "¥"
AccessKey: "" # apilayer.com API Key(汇率换算,选填)
# ── Telegram Bot(可选)──────────────────────────────────────────────────
Telegram:
Enable: false
BotId: 0
BotName: ""
BotToken: ""
EnableNotify: false
WebHookDomain: ""
+1
View File
@@ -10,4 +10,5 @@ reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tokio = { version = "1", features = ["time"] } tokio = { version = "1", features = ["time"] }
tracing = "0.1"
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
+14
View File
@@ -54,6 +54,20 @@ pub async fn verify_with_key(
Ok(resp.success) Ok(resp.success)
} }
/// Guard: verify a token and return `Err(TOO_MANY_REQUESTS)` on failure.
///
/// Convenience wrapper used by auth service handlers — avoids repeating the
/// same error mapping in every caller.
pub async fn guard(secret: &str, token: &str, ip: &str) -> anyhow::Result<()> {
let ok = verify(secret, token, ip).await.unwrap_or(false);
if !ok {
// Use a plain anyhow error with a sentinel message; callers in the
// main crate map this to `error_code::TOO_MANY_REQUESTS`.
anyhow::bail!("turnstile_failed");
}
Ok(())
}
/// Generate a random UUID suitable for use as an idempotency key. /// Generate a random UUID suitable for use as an idempotency key.
pub fn random_uuid() -> String { pub fn random_uuid() -> String {
uuid::Uuid::new_v4().to_string() uuid::Uuid::new_v4().to_string()
+9 -3
View File
@@ -77,11 +77,17 @@ pub fn register_all(repos: Arc<Repositories>, config: Arc<Config>) -> ServeMux {
}, },
); );
// ── Quota task ──────────────────────────────────────────────────────────── // ── Quota task + Rate task ─────────────────────────────────────────────────
let quota_repos = Arc::clone(&repos); let quota_repos = Arc::clone(&repos);
let quota_config = Arc::clone(&config); let quota_config = Arc::clone(&config);
mux.handle_async_func(crate::queue::types::FORTHWITH_QUOTA_TASK, move |task| { mux.handle_async_func(crate::queue::types::FORTHWITH_QUOTA_TASK, move |t| {
task::quota_task(task, Arc::clone(&quota_repos), Arc::clone(&quota_config)) task::quota_task(t, Arc::clone(&quota_repos), Arc::clone(&quota_config))
});
let rate_repos = Arc::clone(&repos);
let rate_config = Arc::clone(&config);
mux.handle_async_func(crate::queue::types::FORTHWITH_RATE_TASK, move |t| {
task::rate_task(t, Arc::clone(&rate_repos), Arc::clone(&rate_config))
}); });
mux mux
+10 -1
View File
@@ -4,7 +4,7 @@ use asynq::error::Result;
use asynq::task::Task; use asynq::task::Task;
use crate::config::Config; use crate::config::Config;
use crate::queue::service::task::QuotaTaskLogic; use crate::queue::service::task::{QuotaTaskLogic, RateLogic};
use crate::repository::Repositories; use crate::repository::Repositories;
pub async fn quota_task(task: Task, repos: Arc<Repositories>, config: Arc<Config>) -> Result<()> { pub async fn quota_task(task: Task, repos: Arc<Repositories>, config: Arc<Config>) -> Result<()> {
@@ -13,3 +13,12 @@ pub async fn quota_task(task: Task, repos: Arc<Repositories>, config: Arc<Config
.await .await
.map_err(|e| asynq::error::Error::other(e.to_string())) .map_err(|e| asynq::error::Error::other(e.to_string()))
} }
/// Fetch the current exchange rate and persist it.
/// Scheduled daily at 01:00 by the scheduler (mirrors `rateLogic.go`).
pub async fn rate_task(_task: Task, repos: Arc<Repositories>, config: Arc<Config>) -> Result<()> {
RateLogic::new(repos, config)
.execute()
.await
.map_err(|e| asynq::error::Error::other(e.to_string()))
}
+54
View File
@@ -250,3 +250,57 @@ impl QuotaTaskLogic {
Ok(()) Ok(())
} }
} }
// ─────────────────────────────────────────────────────────────────────────────
// RateLogic — port of `server/queue/logic/task/rateLogic.go`
// Fetches the current exchange rate for the site currency and caches it.
// ─────────────────────────────────────────────────────────────────────────────
pub struct RateLogic {
repos: Arc<Repositories>,
config: Arc<Config>,
}
impl RateLogic {
pub fn new(repos: Arc<Repositories>, config: Arc<Config>) -> Self {
Self { repos, config }
}
/// Fetch exchange rate from apilayer.com and cache it in Redis.
///
/// Skips silently when `Currency.AccessKey` is not configured.
pub async fn execute(&self) -> anyhow::Result<()> {
let access_key = &self.config.currency.access_key;
if access_key.is_empty() {
tracing::debug!("[RateLogic] skip: no Currency.AccessKey configured");
return Ok(());
}
let from = &self.config.currency.unit;
if from.is_empty() || from.to_uppercase() == "CNY" {
// Already in base currency — rate is 1.0, nothing to fetch.
tracing::debug!("[RateLogic] skip: currency unit is CNY or empty");
return Ok(());
}
match crate::exchange_rate::convert(from, "CNY", 1.0, access_key).await {
Ok(rate) => {
tracing::info!(
"[RateLogic] exchange rate {from}→CNY = {rate:.6}"
);
// Persist rate to system config table.
let _ = self
.repos
.system
.update_value_by_category_key("currency", "exchange_rate", &rate.to_string())
.await
.map_err(|e| tracing::warn!("[RateLogic] persist rate failed: {e}"));
}
Err(e) => {
tracing::error!("[RateLogic] fetch exchange rate failed: {e}");
return Err(e);
}
}
Ok(())
}
}
+2
View File
@@ -28,3 +28,5 @@ pub const FORTHWITH_TRAFFIC_STATISTICS: &str = "forthwith:traffic:statistics";
// ── task / quota ─────────────────────────────────────────────────────── // ── task / quota ───────────────────────────────────────────────────────
pub const FORTHWITH_QUOTA_TASK: &str = "forthwith:quota:task"; pub const FORTHWITH_QUOTA_TASK: &str = "forthwith:quota:task";
/// Daily exchange-rate refresh (mirrors Go `ForthwithRateTask`).
pub const FORTHWITH_RATE_TASK: &str = "forthwith:rate:task";
+1
View File
@@ -10,3 +10,4 @@ pub mod telephone_user_register_service;
pub mod trial_cache; pub mod trial_cache;
pub mod user_login_service; pub mod user_login_service;
pub mod user_register_service; pub mod user_register_service;
pub mod utils;
@@ -32,6 +32,14 @@ impl ResetPasswordService {
&self, &self,
req: ResetPasswordRequest, req: ResetPasswordRequest,
) -> Result<LoginResponse, anyhow::Error> { ) -> Result<LoginResponse, anyhow::Error> {
// Turnstile (mirrors Go: Verify.ResetPasswordVerify && !Debug)
super::utils::check_turnstile(
self.config.verify.reset_password_verify && self.config.model != "dev",
&self.config.verify.turnstile_secret,
&req.cf_token,
&req.ip,
).await?;
let cache_key = format!("{}:2:{}", AUTH_CODE_CACHE_KEY, req.email); let cache_key = format!("{}:2:{}", AUTH_CODE_CACHE_KEY, req.email);
let cached = self let cached = self
.cache .cache
@@ -25,6 +25,14 @@ impl TelephoneLoginService {
} }
pub async fn login(&self, req: TelephoneLoginRequest) -> Result<LoginResponse, anyhow::Error> { pub async fn login(&self, req: TelephoneLoginRequest) -> Result<LoginResponse, anyhow::Error> {
// Turnstile (mirrors Go: Verify.LoginVerify && !Debug)
super::utils::check_turnstile(
self.config.verify.login_verify && self.config.model != "dev",
&self.config.verify.turnstile_secret,
&req.cf_token,
&req.ip,
).await?;
let phone = format!("+{}{}", req.telephone_area_code, req.telephone); let phone = format!("+{}{}", req.telephone_area_code, req.telephone);
let auth_method = self.repos.user.find_auth_method_by_open_id("mobile", &phone).await let auth_method = self.repos.user.find_auth_method_by_open_id("mobile", &phone).await
@@ -25,6 +25,14 @@ impl TelephoneResetPasswordService {
} }
pub async fn reset(&self, req: TelephoneResetPasswordRequest) -> Result<LoginResponse, anyhow::Error> { pub async fn reset(&self, req: TelephoneResetPasswordRequest) -> Result<LoginResponse, anyhow::Error> {
// Turnstile (mirrors Go: Verify.ResetPasswordVerify && !Debug)
super::utils::check_turnstile(
self.config.verify.reset_password_verify && self.config.model != "dev",
&self.config.verify.turnstile_secret,
&req.cf_token,
&req.ip,
).await?;
let phone = format!("+{}{}", req.telephone_area_code, req.telephone); let phone = format!("+{}{}", req.telephone_area_code, req.telephone);
let cache_key = format!("{}:{}", crate::config::cache_key::AUTH_CODE_TELEPHONE_CACHE_KEY, phone); let cache_key = format!("{}:{}", crate::config::cache_key::AUTH_CODE_TELEPHONE_CACHE_KEY, phone);
@@ -26,6 +26,14 @@ impl TelephoneUserRegisterService {
} }
pub async fn register(&self, req: TelephoneRegisterRequest) -> Result<LoginResponse, anyhow::Error> { pub async fn register(&self, req: TelephoneRegisterRequest) -> Result<LoginResponse, anyhow::Error> {
// Turnstile (mirrors Go: Verify.RegisterVerify && !Debug)
super::utils::check_turnstile(
self.config.verify.register_verify && self.config.model != "dev",
&self.config.verify.turnstile_secret,
&req.cf_token,
&req.ip,
).await?;
if self.config.register.stop_register { if self.config.register.stop_register {
return Err(anyhow!(CodeError::new_err_code(error_code::STOP_REGISTER))); return Err(anyhow!(CodeError::new_err_code(error_code::STOP_REGISTER)));
} }
+8
View File
@@ -24,6 +24,14 @@ impl UserLoginService {
} }
pub async fn login(&self, req: UserLoginRequest) -> Result<LoginResponse, anyhow::Error> { pub async fn login(&self, req: UserLoginRequest) -> Result<LoginResponse, anyhow::Error> {
// Turnstile verification (mirrors Go: Verify.LoginVerify && !Debug)
super::utils::check_turnstile(
self.config.verify.login_verify && self.config.model != "dev",
&self.config.verify.turnstile_secret,
&req.cf_token,
&req.ip,
).await?;
let user = self let user = self
.repos.user.find_one_by_email(&req.email).await .repos.user.find_one_by_email(&req.email).await
.map_err(|_| anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)))? .map_err(|_| anyhow!(CodeError::new_err_code(error_code::DATABASE_QUERY_ERROR)))?
@@ -26,6 +26,14 @@ impl UserRegisterService {
} }
pub async fn register(&self, req: UserRegisterRequest) -> Result<LoginResponse, anyhow::Error> { pub async fn register(&self, req: UserRegisterRequest) -> Result<LoginResponse, anyhow::Error> {
// Turnstile (mirrors Go: Verify.RegisterVerify && !Debug)
super::utils::check_turnstile(
self.config.verify.register_verify && self.config.model != "dev",
&self.config.verify.turnstile_secret,
&req.cf_token,
&req.ip,
).await?;
let cfg = &self.config.register; let cfg = &self.config.register;
if cfg.stop_register { if cfg.stop_register {
+25
View File
@@ -0,0 +1,25 @@
//! Shared helpers for auth services.
use anyhow::anyhow;
use result::code_error::CodeError;
use result::error_code;
/// Verify a Cloudflare Turnstile token.
///
/// Returns `Err(TOO_MANY_REQUESTS)` when verification fails.
/// Call-site guards the `debug` flag — pass `enabled = config.verify.login_verify && config.model != "dev"`.
pub async fn check_turnstile(
enabled: bool,
secret: &str,
token: &str,
ip: &str,
) -> anyhow::Result<()> {
if !enabled {
return Ok(());
}
let ok = turnstile::verify(secret, token, ip).await.unwrap_or(false);
if !ok {
return Err(anyhow!(CodeError::new_err_code(error_code::TOO_MANY_REQUESTS)));
}
Ok(())
}