12451 9e935fdda9 refactor(api-server): 8 个超 80 行函数全部拆分完毕(行为零改动)+ 补 171 条 SQL 快照守门断言
## 结果

| 函数 | 前 | 后 | 拆出的 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` 语义(
  未配置=不限制、总管理员豁免、失败安全退回"不限制")**逐字未改**。
2026-09-20 05:22:56 +08:00

645 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 认证路由 — 登录/登出/当前用户信息
//!
//! - 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"
})))
}