## 结果 | 函数 | 前 | 后 | 拆出的 helper | |---|---:|---:|---| | `db/migrate.rs::run` | 174 | 20 | `apply_schema` / `align_column_metadata` / `create_indexes_and_columns` / `align_legacy_structures` / `seed_defaults` | | `routes/auth.rs::login` | 95 | 77 | `login_guard` | | `workers/report_worker.rs::handle_auth_str` | 88 | 70 | `deliver_auth_str` | | `middleware/auth.rs::auth_middleware` | 87 | 71 | `load_effective_scope` | | `routes/h5.rs::get_compartment_detail` | 87 | 65 | `build_compartment_detail_response` | | `workers/report_worker.rs::settle_finished_order` | 85 | 71 | `write_back_order_energy` | | `routes/charge_orders.rs::list_charge_orders` | 84 | 61 | `append_order_filters` | | `routes/cabinets.rs::get_cabinet_detail` | 83 | 74 | `ensure_cabinet_access` | **全仓超 80 行的生产函数:0 个**(重构前 8 个)。`routes/mod.rs` 的 `.route(` 仍 **80**。 ## 怎么保证"行为零改动":机械验证器,不是靠人眼 新增本地工具 `.git/verify_refactor.py`(不入库),对每个文件给出三条判据: 1. **被移除的非注释代码行 = 0** —— 即原可执行语句**全部还在** (搬进 helper 的行仍存在,故不计为移除;只有真正被改写/删掉的行才会出现); 2. 新增的非注释代码行**只有脚手架**(函数签名/花括号/`Ok(())`/调用/`let` 绑定/尾表达式); 3. 文件内 `r#"..."#` **SQL 字面量逐条与 HEAD 相同**。 7 个文件**全部通过三条**(被移除均为 0;`cabinets.rs` 12 条、`h5.rs` 27 条、 `migrate.rs` 7 条、`middleware/auth.rs` 3 条 SQL 字面量逐条一致)。 > ⚠️ **这个验证器本身被自证抓出过一个假绿**:早期版本用反斜杠路径调 > `git show HEAD:<path>`,失败后"跳过并返回通过"——等于什么都没查。 > 已修:路径强制正斜杠;**已跟踪文件取不到 HEAD 内容时判为"验证器失效/本项未验证"并 exit 1**。 > 实测植入 `VARCHAR(20)→VARCHAR(30)` 后 exit 1 且列出被移除行。 ## 补 171 条 SQL 快照守门断言 这些 handler **原本没有任何测试覆盖**(tasks/002 的风险结论)。 新增 `routes/mod.rs::sql_snapshot_guards`:从 7 个文件**程序化提取**全部含 SQL 的字面量, 用 `include_str!` 把源文件本身作为被测对象 —— **SQL 文本/字段顺序/`?` 占位符一旦被改,测试立即失败** (覆盖 `auth.rs` 5 + `cabinets.rs` 27 + `charge_orders.rs` 14 + `h5.rs` 52 + `middleware/auth.rs` 18 + `report_worker.rs` 27 + `migrate.rs` 28)。 > ⚠️ **第一版守门断言也是假绿,靠自证发现**:提取规则只收"以 `SELECT` 开头"的字面量, > 于是 `charge_orders.rs` 只捕到 2 条,**恰恰漏掉与 `.bind` 顺序一一对应的 > `" AND cabinet_id = ?"` 等片段** —— 我把 SQL 改了一个字符,断言却仍然通过。 > 已放宽为正则(含 `?` 且以 SQL 子句开头 / 含 SQL 关键字),该文件覆盖 2 → 14 条。 > 重新自证:改字符 → **FAILED 并打印出错片段**;还原 → 通过。 ## 顺带修掉两处"由我自己的判据逼出来的代码退化" 子代理如实报告:它们为迁就验证器**过窄**的 SCAFFOLD 正则,做了两处不该做的让步 —— 这是判据塑造代码,必须纠正: 1. `append_order_filters` 被写成**体内无 `await` 的 `async fn`**(`clippy::unused_async` 的典型告警) → 改为同步 `fn`,调用点去掉 `.await`; 2. `ensure_cabinet_access` / `login_guard` 的签名被压成**单行 113+ 字符** → 恢复常规多行格式。 验证器的 SCAFFOLD 正则同步放宽(支持多行签名/形参行、普通与非 await 调用、裸标识符尾表达式), 放宽后 7 个文件复检仍全过。 ## 验收 - `cargo check --all-targets`:**0 error / 0 warning**; - `cargo test --bin pms-api`:**189 passed**(基线 182 + 7 条守门,**未减少**); - 长函数:**0 个**;路由:**80 条**; - 错误处理口径逐条保留(`?` 上抛 vs 只记 `ERROR` 未互换),已在各 helper 的 `///` 文档注明来源与口径;`middleware/auth.rs` 的组织隔离/`user_scopes` 语义( 未配置=不限制、总管理员豁免、失败安全退回"不限制")**逐字未改**。
645 lines
24 KiB
Rust
645 lines
24 KiB
Rust
//! 认证路由 — 登录/登出/当前用户信息
|
||
//!
|
||
//! - GET /api/auth/challenge — 获取登录 challenge(防暴力破解)
|
||
//! - POST /api/auth/login — 手机号+密码登录,返回 JWT token
|
||
//! - POST /api/auth/logout — 登出(客户端清除 token 即可,服务端无状态)
|
||
//! - GET /api/auth/me — 获取当前登录用户信息及权限码
|
||
//! - POST /api/auth/wx-login — 微信小程序手机号快速登录
|
||
//! - PUT /api/auth/avatar — 更新用户头像 URL
|
||
|
||
use axum::extract::{Multipart, State};
|
||
use axum::http::HeaderMap;
|
||
use axum::Json;
|
||
use serde::Deserialize;
|
||
use serde_json::{json, Value};
|
||
use std::io::Write;
|
||
use uuid::Uuid;
|
||
|
||
const AVATAR_DIR: &str = "uploads/avatars";
|
||
use reqwest::Client;
|
||
|
||
use super::upload_guard;
|
||
use crate::captcha;
|
||
use crate::commands::AppState;
|
||
use crate::error::AppError;
|
||
use crate::middleware::auth::{self, CurrentUser};
|
||
|
||
/// 登录失败计数阈值:同一手机号 / 同一 IP 在窗口期内最多允许的失败次数
|
||
const LOGIN_FAIL_MAX: i64 = 5;
|
||
/// 登录失败计数窗口(秒)—— Redis TTL,到期自动清零
|
||
const LOGIN_FAIL_WINDOW_SECS: u64 = 900;
|
||
/// challenge 发码限流:同一 IP 每分钟最多请求次数(免认证接口,防 CPU 打满)
|
||
const CHALLENGE_RATE_MAX: i64 = 10;
|
||
/// challenge 发码限流窗口(秒)
|
||
const CHALLENGE_RATE_WINDOW_SECS: u64 = 60;
|
||
/// 登录失败统一文案(锁定期同样返回它,不泄露"账号已锁"等可枚举信息)
|
||
const LOGIN_FAIL_MESSAGE: &str = "手机号或密码错误";
|
||
/// 客户端 IP 请求头 —— 只信任 nginx `proxy_set_header X-Real-IP $remote_addr` 注入的值,
|
||
/// 不读取客户端可任意伪造的 `X-Forwarded-For` 链
|
||
const CLIENT_IP_HEADER: &str = "x-real-ip";
|
||
/// 取不到 IP 时的兜底标识(无头直连请求共用一个计数桶)
|
||
const UNKNOWN_CLIENT_IP: &str = "unknown";
|
||
|
||
/// 从请求头取客户端 IP(仅信任 X-Real-IP,取不到时用固定兜底值)
|
||
fn client_ip(headers: &HeaderMap) -> String {
|
||
headers
|
||
.get(CLIENT_IP_HEADER)
|
||
.and_then(|v| v.to_str().ok())
|
||
.map(str::trim)
|
||
.filter(|s| !s.is_empty())
|
||
.unwrap_or(UNKNOWN_CLIENT_IP)
|
||
.to_string()
|
||
}
|
||
|
||
/// Redis 自增计数:键首次创建时设置 TTL(固定窗口,不因后续自增而延长)
|
||
async fn incr_with_ttl(state: &AppState, key: &str, ttl_secs: u64) -> Result<i64, AppError> {
|
||
let mut conn = state.redis.clone();
|
||
let count: i64 = redis::cmd("INCR").arg(key).query_async(&mut conn).await?;
|
||
if count == 1 {
|
||
let _: () = redis::cmd("EXPIRE")
|
||
.arg(key)
|
||
.arg(ttl_secs)
|
||
.query_async(&mut conn)
|
||
.await?;
|
||
}
|
||
Ok(count)
|
||
}
|
||
|
||
/// 读取计数(键不存在按 0);Redis 异常时按 0 处理(fail-open,仅记 ERROR 日志)
|
||
///
|
||
/// fail-open 的理由:登录链路本身已依赖 Redis(验证码校验失败即拒绝),
|
||
/// 这里不重复阻断,避免缓存抖动导致全体用户无法登录。
|
||
async fn read_counter(state: &AppState, key: &str) -> i64 {
|
||
let mut conn = state.redis.clone();
|
||
match redis::cmd("GET")
|
||
.arg(key)
|
||
.query_async::<Option<String>>(&mut conn)
|
||
.await
|
||
{
|
||
Ok(Some(v)) => v.parse::<i64>().unwrap_or(0),
|
||
Ok(None) => 0,
|
||
Err(e) => {
|
||
tracing::error!(err = %e, key = %key, "读取登录失败计数失败,按 0 处理");
|
||
0
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 清除计数(登录成功后调用);失败仅记 ERROR 日志,不影响登录结果
|
||
async fn clear_counter(state: &AppState, key: &str) {
|
||
let mut conn = state.redis.clone();
|
||
if let Err(e) = redis::cmd("DEL").arg(key).query_async::<()>(&mut conn).await {
|
||
tracing::error!(err = %e, key = %key, "清除登录失败计数失败");
|
||
}
|
||
}
|
||
|
||
/// 记录一次登录失败:手机号 + IP 双维度各自自增,达到阈值记 WARN
|
||
///
|
||
/// 返回两个维度的较大计数(供调用方日志使用)。
|
||
async fn record_login_failure(
|
||
state: &AppState,
|
||
phone_key: &str,
|
||
ip_key: &str,
|
||
phone: &str,
|
||
ip: &str,
|
||
) -> i64 {
|
||
let phone_fails = match incr_with_ttl(state, phone_key, LOGIN_FAIL_WINDOW_SECS).await {
|
||
Ok(n) => n,
|
||
Err(e) => {
|
||
tracing::error!(err = %e, key = %phone_key, "手机号维度失败计数写入失败");
|
||
0
|
||
}
|
||
};
|
||
let ip_fails = match incr_with_ttl(state, ip_key, LOGIN_FAIL_WINDOW_SECS).await {
|
||
Ok(n) => n,
|
||
Err(e) => {
|
||
tracing::error!(err = %e, key = %ip_key, "IP 维度失败计数写入失败");
|
||
0
|
||
}
|
||
};
|
||
if phone_fails >= LOGIN_FAIL_MAX || ip_fails >= LOGIN_FAIL_MAX {
|
||
tracing::warn!(
|
||
phone = %phone, ip = %ip, phone_fails, ip_fails, window_secs = LOGIN_FAIL_WINDOW_SECS,
|
||
"登录失败次数达到阈值,该维度已锁定"
|
||
);
|
||
}
|
||
phone_fails.max(ip_fails)
|
||
}
|
||
|
||
/// 登录请求体
|
||
#[derive(Debug, Deserialize)]
|
||
pub(crate) struct LoginInput {
|
||
phone: String,
|
||
password: String,
|
||
/// 登录 challenge token,从 GET /api/auth/challenge 获取
|
||
#[serde(default)]
|
||
challenge_id: String,
|
||
/// 滑块验证码偏移量(前端拖拽拼图后获得)
|
||
#[serde(default)]
|
||
captcha_x: Option<i32>,
|
||
}
|
||
|
||
/// GET /api/auth/challenge — 获取登录滑块验证码
|
||
///
|
||
/// 返回背景图(带缺口)和拼图块图,前端拖动对齐后提交偏移量。
|
||
/// 该接口免认证,故按客户端 IP 限流(10 次/分钟),并配合 `captcha` 内部的渲染并发闸门,
|
||
/// 避免被用来打满 CPU(第二道限流由 nginx `limit_req` 承担)。
|
||
pub async fn get_challenge(
|
||
State(state): State<AppState>,
|
||
headers: HeaderMap,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let ip = client_ip(&headers);
|
||
let rate_key = format!("auth:challenge:rate:{}", ip);
|
||
let count = incr_with_ttl(&state, &rate_key, CHALLENGE_RATE_WINDOW_SECS).await?;
|
||
if count > CHALLENGE_RATE_MAX {
|
||
tracing::warn!(ip = %ip, count, max = CHALLENGE_RATE_MAX, "challenge 请求超过限流阈值,已拒绝");
|
||
return Err(AppError::TooManyRequests("请求过于频繁,请稍后再试".into()));
|
||
}
|
||
|
||
let data = captcha::create_challenge(&state.redis_url).await?;
|
||
|
||
tracing::debug!("[challenge] 生成 cid={}", data.challenge_id);
|
||
Ok(Json(json!({
|
||
"code": 0,
|
||
"data": {
|
||
"challenge_id": data.challenge_id,
|
||
"bg_image": data.bg_image,
|
||
"piece_image": data.piece_image,
|
||
},
|
||
"message": "ok"
|
||
})))
|
||
}
|
||
|
||
/// 登录前置闸门:失败计数锁定检查 + 滑块验证码校验。
|
||
///
|
||
/// 本函数由 `login` **原样搬出**,可执行语句、SQL、控制流、错误口径与执行顺序逐条不变:
|
||
/// - 锁定检查放在验证码之前:锁定期直接拒绝,不消耗验证码、不做图像/DB 开销;
|
||
/// 锁定期与密码错误返回**完全一致**的文案(`LOGIN_FAIL_MESSAGE`),
|
||
/// 不泄露账号是否存在 / 是否被锁。
|
||
/// - 校验滑块验证码(防暴力破解 + 人机验证):缺失验证参数或验证码错误返回 `BadRequest`。
|
||
///
|
||
/// 返回 `Ok(())` 表示前置检查全部通过;所有错误口径与搬出前一致,
|
||
/// 由调用方 `login` 以 `?` 原样上抛(`Unauthorized` / `BadRequest` 不变)。
|
||
async fn login_guard(
|
||
state: &AppState,
|
||
input: &LoginInput,
|
||
phone_key: &str,
|
||
ip_key: &str,
|
||
ip: &str,
|
||
) -> Result<(), AppError> {
|
||
// 锁定检查放在验证码之前:锁定期直接拒绝,不消耗验证码、不做图像/DB 开销
|
||
let phone_fails = read_counter(&state, &phone_key).await;
|
||
let ip_fails = read_counter(&state, &ip_key).await;
|
||
if phone_fails >= LOGIN_FAIL_MAX || ip_fails >= LOGIN_FAIL_MAX {
|
||
tracing::warn!(
|
||
phone = %input.phone, ip = %ip, phone_fails, ip_fails,
|
||
"登录已锁定,拒绝本次尝试(返回统一文案)"
|
||
);
|
||
return Err(AppError::Unauthorized(LOGIN_FAIL_MESSAGE.into()));
|
||
}
|
||
|
||
// 校验滑块验证码(防暴力破解 + 人机验证)
|
||
if input.challenge_id.is_empty() {
|
||
return Err(AppError::BadRequest("缺少登录验证参数".into()));
|
||
}
|
||
let captcha_x = input.captcha_x.unwrap_or(i32::MAX);
|
||
let valid = captcha::verify_offset(&state.redis_url, &input.challenge_id, captcha_x).await?;
|
||
if !valid {
|
||
return Err(AppError::BadRequest("验证码错误,请刷新页面重试".into()));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// POST /api/auth/login — 用户登录
|
||
///
|
||
/// 防暴力破解:按「手机号 + 客户端 IP」双维度计数(Redis,TTL 15 分钟),
|
||
/// 任一维度达到 5 次失败即锁定该维度;锁定期与密码错误返回**完全一致**的文案,
|
||
/// 不泄露账号是否存在 / 是否被锁。登录成功后两个维度计数清零。
|
||
pub async fn login(
|
||
State(state): State<AppState>,
|
||
headers: HeaderMap,
|
||
Json(input): Json<LoginInput>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let pool = &state.mysql;
|
||
|
||
if input.phone.is_empty() || input.password.is_empty() {
|
||
return Err(AppError::BadRequest("手机号和密码不能为空".into()));
|
||
}
|
||
|
||
let ip = client_ip(&headers);
|
||
let phone_key = format!("auth:fail:phone:{}", input.phone);
|
||
let ip_key = format!("auth:fail:ip:{}", ip);
|
||
|
||
// 锁定检查 + 滑块验证码校验:原样搬入 login_guard,此处仅按原顺序调用
|
||
login_guard(&state, &input, &phone_key, &ip_key, &ip).await?;
|
||
|
||
// 查询用户(含角色和组织信息)
|
||
let user_row: Option<(i64, Option<String>, String, String, i32, i32, Option<i64>)> = sqlx::query_as(
|
||
"SELECT id, name, phone, password, status, role, organization_id FROM users WHERE phone = ?",
|
||
)
|
||
.bind(&input.phone)
|
||
.fetch_optional(pool)
|
||
.await?;
|
||
|
||
let Some((id, name, phone, stored_password, status, role_level, organization_id)) = user_row
|
||
else {
|
||
// 账号不存在也计入失败:避免用"是否存在"做账号枚举
|
||
let fails =
|
||
record_login_failure(&state, &phone_key, &ip_key, &input.phone, &ip).await;
|
||
tracing::warn!(phone = %input.phone, ip = %ip, fails, reason = "user_not_found", "登录失败");
|
||
return Err(AppError::Unauthorized(LOGIN_FAIL_MESSAGE.into()));
|
||
};
|
||
|
||
// 检查账户状态
|
||
if status == 0 {
|
||
return Err(AppError::Forbidden("账户已被禁用".into()));
|
||
}
|
||
|
||
// 密码校验(argon2 哈希比对)
|
||
let valid = auth::verify_password(&input.password, &stored_password)
|
||
.map_err(|_| AppError::Internal("密码验证失败".into()))?;
|
||
if !valid {
|
||
let fails = record_login_failure(&state, &phone_key, &ip_key, &phone, &ip).await;
|
||
tracing::warn!(phone = %phone, ip = %ip, fails, reason = "bad_password", "登录失败");
|
||
return Err(AppError::Unauthorized(LOGIN_FAIL_MESSAGE.into()));
|
||
}
|
||
|
||
// 登录成功:双维度失败计数清零
|
||
clear_counter(&state, &phone_key).await;
|
||
clear_counter(&state, &ip_key).await;
|
||
tracing::info!(user_id = id, phone = %phone, ip = %ip, "登录成功");
|
||
|
||
// 生成 JWT token
|
||
let token = auth::generate_token(id, &phone, &state.jwt_secret)?;
|
||
|
||
// 查询用户权限码
|
||
let permissions = auth::load_user_permissions(pool, id).await?;
|
||
let perm_list: Vec<String> = permissions.into_iter().collect();
|
||
|
||
Ok(Json(json!({
|
||
"code": 0,
|
||
"data": {
|
||
"token": token,
|
||
"user": {
|
||
"id": id,
|
||
"name": name,
|
||
"phone": phone,
|
||
"role_level": role_level,
|
||
"organization_id": organization_id,
|
||
"permissions": perm_list,
|
||
}
|
||
},
|
||
"message": "登录成功"
|
||
})))
|
||
}
|
||
|
||
/// POST /api/auth/logout — 用户登出
|
||
pub async fn logout() -> Json<Value> {
|
||
// JWT 无状态,客户端清除 token 即可
|
||
Json(json!({ "code": 0, "data": null, "message": "登出成功" }))
|
||
}
|
||
|
||
/// GET /api/auth/me — 获取当前用户信息及权限
|
||
#[allow(clippy::type_complexity)]
|
||
pub async fn me(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let pool = &state.mysql;
|
||
// 查询用户详细信息
|
||
let row: Option<(i64, Option<String>, String, i32, Option<i64>, Option<String>)> = sqlx::query_as(
|
||
"SELECT id, name, phone, role, organization_id, avatar FROM users WHERE id = ?"
|
||
)
|
||
.bind(user.user_id)
|
||
.fetch_optional(pool)
|
||
.await?;
|
||
|
||
let (id, name, phone, role, org_id, avatar) =
|
||
row.ok_or_else(|| AppError::NotFound("用户不存在".into()))?;
|
||
|
||
let perm_list: Vec<String> = user.permissions.into_iter().collect();
|
||
|
||
Ok(Json(json!({
|
||
"code": 0,
|
||
"data": {
|
||
"id": id,
|
||
"name": name,
|
||
"phone": phone,
|
||
"role": role,
|
||
"organization_id": org_id,
|
||
"avatar": avatar,
|
||
"permissions": perm_list,
|
||
},
|
||
"message": "ok"
|
||
})))
|
||
}
|
||
|
||
/// 微信扫码登录请求体
|
||
#[derive(Debug, Deserialize)]
|
||
pub struct WxLoginInput {
|
||
/// wx.login() 获取的临时 code
|
||
pub code: String,
|
||
/// getPhoneNumber 获取的 phone code
|
||
pub phone_code: String,
|
||
}
|
||
|
||
/// 微信 jscode2session 响应
|
||
///
|
||
/// 字段与微信开放平台返回体一一对齐(当前仅读取 openid/errmsg,其余为预留能力,
|
||
/// 见各字段注释),故整体允许 dead_code,避免误删上游协议字段。
|
||
#[derive(Debug, Deserialize)]
|
||
#[allow(dead_code)]
|
||
struct JsCode2SessionResp {
|
||
#[serde(default)]
|
||
openid: Option<String>,
|
||
/// 会话密钥:预留给敏感数据解密(如 encryptedData/水印校验),当前登录流程不需要
|
||
#[serde(default)]
|
||
session_key: Option<String>,
|
||
/// 微信错误码:预留用于后续按错误码细分排障(当前仅用 errmsg 文案)
|
||
#[serde(default)]
|
||
errcode: Option<i64>,
|
||
#[serde(default)]
|
||
errmsg: Option<String>,
|
||
}
|
||
|
||
/// 微信 access_token 响应
|
||
///
|
||
/// 字段与微信开放平台返回体一一对齐(当前仅读取 access_token/errmsg,其余为预留能力),
|
||
/// 故整体允许 dead_code。
|
||
#[derive(Debug, Deserialize)]
|
||
#[allow(dead_code)]
|
||
struct TokenResp {
|
||
#[serde(default)]
|
||
access_token: Option<String>,
|
||
/// 有效期秒数:预留用于 access_token 本地缓存(当前每次登录都重新获取)
|
||
#[serde(default)]
|
||
expires_in: Option<i64>,
|
||
/// 微信错误码:预留用于后续按错误码细分排障
|
||
#[serde(default)]
|
||
errcode: Option<i64>,
|
||
#[serde(default)]
|
||
errmsg: Option<String>,
|
||
}
|
||
|
||
/// 微信 getuserphonenumber 响应
|
||
///
|
||
/// 字段与微信开放平台返回体一一对齐(当前仅读取 phone_info/errmsg),故整体允许 dead_code。
|
||
#[derive(Debug, Deserialize)]
|
||
#[allow(dead_code)]
|
||
struct PhoneNumberResp {
|
||
/// 微信错误码:预留用于后续按错误码细分排障
|
||
#[serde(default)]
|
||
errcode: Option<i64>,
|
||
#[serde(default)]
|
||
errmsg: Option<String>,
|
||
#[serde(default)]
|
||
phone_info: Option<PhoneInfo>,
|
||
}
|
||
|
||
/// 微信手机号信息
|
||
///
|
||
/// 当前业务用 pure_phone_number 匹配用户;带国码的 phone_number 与 country_code
|
||
/// 预留用于国际号段/归属地展示,故整体允许 dead_code。
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
#[allow(dead_code)]
|
||
struct PhoneInfo {
|
||
/// 带国码手机号(如 8613951516272),预留国际号段场景
|
||
#[serde(default)]
|
||
phone_number: String,
|
||
#[serde(default)]
|
||
pure_phone_number: String,
|
||
/// 国家码(如 86),预留号段归属展示
|
||
#[serde(default)]
|
||
country_code: String,
|
||
}
|
||
|
||
/// 读取微信小程序凭证(环境变量缺失则报内部错误)
|
||
fn wechat_credentials() -> Result<(String, String), AppError> {
|
||
let appid = std::env::var("WECHAT_APPID")
|
||
.map_err(|_| AppError::Internal("WECHAT_APPID 未配置".into()))?;
|
||
let secret = std::env::var("WECHAT_APPSECRET")
|
||
.map_err(|_| AppError::Internal("WECHAT_APPSECRET 未配置".into()))?;
|
||
Ok((appid, secret))
|
||
}
|
||
|
||
/// 用 wx.login() 的 code 换取 openid
|
||
async fn fetch_openid(client: &Client, appid: &str, secret: &str, code: &str) -> Result<String, AppError> {
|
||
let jscode_url = format!(
|
||
"https://api.weixin.qq.com/sns/jscode2session?appid={}&secret={}&js_code={}&grant_type=authorization_code",
|
||
appid, secret, code
|
||
);
|
||
let js_resp: JsCode2SessionResp = client.get(&jscode_url).send().await
|
||
.map_err(|e| AppError::Internal(format!("jscode2session 请求失败: {}", e)))?
|
||
.json().await
|
||
.map_err(|e| AppError::Internal(format!("jscode2session 解析失败: {}", e)))?;
|
||
|
||
js_resp.openid.ok_or_else(|| {
|
||
let err = js_resp.errmsg.unwrap_or_default();
|
||
AppError::Internal(format!("jscode2session 失败: {}", err))
|
||
})
|
||
}
|
||
|
||
/// 获取微信接口调用凭证 access_token
|
||
async fn fetch_access_token(client: &Client, appid: &str, secret: &str) -> Result<String, AppError> {
|
||
let token_url = format!(
|
||
"https://api.weixin.qq.com/cgi-bin/token?appid={}&secret={}&grant_type=client_credential",
|
||
appid, secret
|
||
);
|
||
let token_resp: TokenResp = client.get(&token_url).send().await
|
||
.map_err(|e| AppError::Internal(format!("token 请求失败: {}", e)))?
|
||
.json().await
|
||
.map_err(|e| AppError::Internal(format!("token 解析失败: {}", e)))?;
|
||
|
||
token_resp.access_token.ok_or_else(|| {
|
||
let err = token_resp.errmsg.unwrap_or_default();
|
||
AppError::Internal(format!("获取 access_token 失败: {}", err))
|
||
})
|
||
}
|
||
|
||
/// 用 phone_code 换取微信绑定的手机号
|
||
async fn fetch_phone_number(
|
||
client: &Client,
|
||
access_token: &str,
|
||
phone_code: &str,
|
||
) -> Result<String, AppError> {
|
||
let phone_url = format!(
|
||
"https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={}",
|
||
access_token
|
||
);
|
||
let phone_resp: PhoneNumberResp = client.post(&phone_url)
|
||
.json(&json!({ "code": phone_code }))
|
||
.send().await
|
||
.map_err(|e| AppError::Internal(format!("getuserphonenumber 请求失败: {}", e)))?
|
||
.json().await
|
||
.map_err(|e| AppError::Internal(format!("getuserphonenumber 解析失败: {}", e)))?;
|
||
|
||
let phone = phone_resp.phone_info
|
||
.ok_or_else(|| {
|
||
let err = phone_resp.errmsg.unwrap_or_default();
|
||
AppError::Internal(format!("获取手机号失败: {}", err))
|
||
})?;
|
||
Ok(phone.pure_phone_number)
|
||
}
|
||
|
||
/// 按手机号查找用户(手机号是唯一标识)
|
||
async fn find_user_by_phone(
|
||
pool: &sqlx::MySqlPool,
|
||
phone_number: &str,
|
||
) -> Result<(i64, Option<String>, String, i32, i32, Option<i64>), AppError> {
|
||
let user_row = sqlx::query_as::<_, (i64, Option<String>, String, i32, i32, Option<i64>)>(
|
||
"SELECT id, name, phone, status, role, organization_id FROM users WHERE phone = ?",
|
||
)
|
||
.bind(phone_number)
|
||
.fetch_optional(pool)
|
||
.await?;
|
||
|
||
user_row.ok_or_else(|| {
|
||
tracing::warn!(phone_number = %phone_number, "微信登录: 手机号未匹配到用户");
|
||
AppError::Unauthorized("用户不存在,请联系管理员添加".into())
|
||
})
|
||
}
|
||
|
||
/// POST /api/auth/wx-login — 微信小程序手机号快速登录
|
||
///
|
||
/// 前端先调用 wx.login() 获取 code,再通过 getPhoneNumber 获取 phone_code,
|
||
/// 后端向微信服务器换取 openid 和手机号,匹配用户后签发 JWT。
|
||
pub async fn wx_login(
|
||
State(state): State<AppState>,
|
||
Json(input): Json<WxLoginInput>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let (appid, secret) = wechat_credentials()?;
|
||
let client = Client::new();
|
||
|
||
// 1. 用 code 换取 openid
|
||
let openid = fetch_openid(&client, &appid, &secret, &input.code).await?;
|
||
|
||
// 2. 获取 access_token
|
||
let access_token = fetch_access_token(&client, &appid, &secret).await?;
|
||
|
||
// 3. 用 phone_code 换取手机号
|
||
let phone_number = fetch_phone_number(&client, &access_token, &input.phone_code).await?;
|
||
|
||
// 4. 按手机号查找用户(手机号是唯一标识)
|
||
tracing::info!(phone_number = %phone_number, "微信登录: 获取到手机号,开始匹配用户");
|
||
let (id, name, _phone, status, role_level, organization_id) =
|
||
find_user_by_phone(&state.mysql, &phone_number).await?;
|
||
|
||
// 5. 存 openid(供后续微信接口调用,如订阅消息/获取用户信息)
|
||
sqlx::query("UPDATE users SET openid = ? WHERE id = ?")
|
||
.bind(&openid)
|
||
.bind(id)
|
||
.execute(&state.mysql)
|
||
.await?;
|
||
|
||
if status == 0 {
|
||
return Err(AppError::Forbidden("账户已被禁用".into()));
|
||
}
|
||
|
||
let token = auth::generate_token(id, &_phone, &state.jwt_secret)?;
|
||
let permissions = auth::load_user_permissions(&state.mysql, id).await?;
|
||
let perm_list: Vec<String> = permissions.into_iter().collect();
|
||
|
||
Ok(Json(json!({
|
||
"code": 0,
|
||
"data": {
|
||
"token": token,
|
||
"user": {
|
||
"id": id,
|
||
"name": name,
|
||
"phone": _phone,
|
||
"role_level": role_level,
|
||
"organization_id": organization_id,
|
||
"permissions": perm_list,
|
||
}
|
||
},
|
||
"message": "登录成功"
|
||
})))
|
||
}
|
||
|
||
/// 微信头像更新请求体
|
||
#[derive(Debug, Deserialize)]
|
||
pub struct AvatarInput {
|
||
pub avatar: String,
|
||
}
|
||
|
||
/// PUT /api/auth/avatar — 更新当前用户头像 URL
|
||
pub async fn update_avatar(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
Json(input): Json<AvatarInput>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
sqlx::query("UPDATE users SET avatar = ? WHERE id = ?")
|
||
.bind(&input.avatar)
|
||
.bind(user.user_id)
|
||
.execute(&state.mysql)
|
||
.await?;
|
||
|
||
Ok(Json(json!({ "code": 0, "data": { "avatar": input.avatar }, "message": "ok" })))
|
||
}
|
||
|
||
/// GET /api/auth/avatar/upload — 头像文件上传
|
||
pub async fn upload_avatar(
|
||
user: CurrentUser,
|
||
State(state): State<AppState>,
|
||
mut multipart: Multipart,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let mut file_data: Option<Vec<u8>> = None;
|
||
let mut ext = "".to_string();
|
||
|
||
while let Some(field) = multipart.next_field().await
|
||
.map_err(|e| AppError::Internal(format!("解析上传文件失败: {}", e)))? {
|
||
let filename = field.file_name().unwrap_or("").to_string();
|
||
let data = field.bytes().await
|
||
.map_err(|e| AppError::Internal(format!("读取文件失败: {}", e)))?;
|
||
|
||
// 提取扩展名
|
||
if let Some(pos) = filename.rfind('.') {
|
||
ext = filename[pos..].to_string();
|
||
}
|
||
file_data = Some(data.to_vec());
|
||
}
|
||
|
||
let data = file_data.ok_or_else(|| AppError::BadRequest("上传内容为空".into()))?;
|
||
if data.is_empty() {
|
||
return Err(AppError::BadRequest("上传内容为空".into()));
|
||
}
|
||
|
||
// 限制大小(2MB)
|
||
if data.len() > 2 * 1024 * 1024 {
|
||
return Err(AppError::BadRequest("头像文件不能超过 2MB".into()));
|
||
}
|
||
|
||
// 扩展名白名单 + 文件魔数嗅探(拒绝 .svg/.htm 等可脚本化类型,防存储型 XSS)
|
||
// 返回值是**按内容嗅探出的**规范扩展名,保证落盘后缀与真实内容一致
|
||
let ext = upload_guard::validate_image_upload(&data, &ext)?;
|
||
|
||
// 保存文件
|
||
let dir = std::path::Path::new(AVATAR_DIR);
|
||
std::fs::create_dir_all(dir)
|
||
.map_err(|e| AppError::Internal(format!("创建目录失败: {}", e)))?;
|
||
|
||
let filename = format!("{}{}", Uuid::new_v4().simple(), ext);
|
||
let filepath = dir.join(&filename);
|
||
let mut f = std::fs::File::create(&filepath)
|
||
.map_err(|e| AppError::Internal(format!("创建文件失败: {}", e)))?;
|
||
f.write_all(&data)
|
||
.map_err(|e| AppError::Internal(format!("写入文件失败: {}", e)))?;
|
||
|
||
// 更新用户头像
|
||
let avatar_url = format!("/uploads/avatars/{}", filename);
|
||
sqlx::query("UPDATE users SET avatar = ? WHERE id = ?")
|
||
.bind(&avatar_url)
|
||
.bind(user.user_id)
|
||
.execute(&state.mysql)
|
||
.await?;
|
||
|
||
tracing::info!(user_id = user.user_id, path = %avatar_url, "头像上传成功");
|
||
|
||
Ok(Json(json!({
|
||
"code": 0,
|
||
"data": { "avatar": avatar_url },
|
||
"message": "ok"
|
||
})))
|
||
}
|