feat: 更新环境配置和错误处理逻辑

- 更新后端端口号和环境配置,添加Redis支持
- 改进错误处理,添加带消息的未授权和禁止访问错误
- 优化前端登录流程和错误提示
- 更新前端页面标题和欢迎信息
- 清理未使用的代码模块
This commit is contained in:
2025-08-29 23:37:34 +08:00
parent dc60a0a4bd
commit e6a9145cd4
11 changed files with 93 additions and 46 deletions

View File

@ -1,7 +1,7 @@
RUST_LOG=info,udmin=debug
APP_ENV=development
APP_HOST=0.0.0.0
APP_PORT=9891
APP_PORT=9898
DB_URL=mysql://root:123456@127.0.0.1:3306/udmin
JWT_SECRET=dev_secret_change_me
JWT_ISS=udmin

View File

@ -1,22 +1,14 @@
# 数据库配置
DATABASE_URL=sqlite://udmin.db?mode=rwc
# JWT配置
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
RUST_LOG=info,udmin=debug
APP_ENV=development
APP_HOST=0.0.0.0
APP_PORT=9898
DB_URL=mysql://root:123456@127.0.0.1:3306/udmin
JWT_SECRET=dev_secret_change_me
JWT_ISS=udmin
JWT_ACCESS_EXP_SECS=1800 # 30分钟
JWT_REFRESH_EXP_SECS=1209600 # 14天
JWT_ACCESS_EXP_SECS=1800
JWT_REFRESH_EXP_SECS=1209600
CORS_ALLOW_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175
# Redis配置
REDIS_URL=redis://:123456@127.0.0.1:6379/9
REDIS_TOKEN_VALIDATION=true # 是否启用Redis token验证
# 服务器配置
APP_HOST=0.0.0.0
APP_PORT=8080
# CORS配置
CORS_ALLOW_ORIGINS=http://localhost:5173
# 日志级别
RUST_LOG=info
REDIS_TOKEN_VALIDATION=true

View File

@ -1,8 +1,13 @@
RUST_LOG=info,udmin=debug
RUST_LOG=info
APP_ENV=prod
APP_HOST=0.0.0.0
# db
APP_PORT=9898
DB_URL=mysql://root:109ysy!@149.104.0.182:3306/udmin
# Redis配置
REDIS_URL=redis://:109ysy!@149.104.0.182:6379/9
REDIS_TOKEN_VALIDATION=true # 是否启用Redis token验证
# jwt
JWT_SECRET=prod_secret_change_me
JWT_ISS=udmin
JWT_ACCESS_EXP_SECS=1800

View File

@ -1,7 +1,7 @@
[package]
name = "migration"
version = "0.1.0"
edition = "2021"
edition = "2024"
[dependencies]
sea-orm-migration = { version = "1.1.14" }

View File

@ -4,7 +4,9 @@ use crate::response::ApiResponse;
#[derive(thiserror::Error, Debug)]
pub enum AppError {
#[error("unauthorized")] Unauthorized,
#[error("unauthorized: {0}")] UnauthorizedMsg(String),
#[error("forbidden")] Forbidden,
#[error("forbidden: {0}")] ForbiddenMsg(String),
#[error("bad request: {0}")] BadRequest(String),
#[error("not found")] NotFound,
#[error(transparent)] Db(#[from] sea_orm::DbErr),
@ -16,7 +18,9 @@ impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
let (status, code, msg) = match &self {
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, 401, "unauthorized".to_string()),
AppError::UnauthorizedMsg(m) => (StatusCode::UNAUTHORIZED, 401, m.clone()),
AppError::Forbidden => (StatusCode::FORBIDDEN, 403, "forbidden".to_string()),
AppError::ForbiddenMsg(m) => (StatusCode::FORBIDDEN, 403, m.clone()),
AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, 400, m.clone()),
AppError::NotFound => (StatusCode::NOT_FOUND, 404, "not found".into()),
_ => (StatusCode::INTERNAL_SERVER_ERROR, 500, "internal error".into()),

View File

@ -7,7 +7,7 @@ pub mod models;
pub mod services;
pub mod routes;
pub mod utils;
pub mod workflow;
//pub mod workflow;
use axum::Router;
use axum::http::{HeaderValue, Method};
@ -17,12 +17,12 @@ use axum::middleware;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// 增强:支持通过 ENV_FILE 指定要加载的环境文件
// 增强:支持通过 ENV_FILE 指定要加载的环境文件,并记录实际加载的文件
// - ENV_FILE=prod 或 production => .env.prod
// - ENV_FILE=dev 或 development => .env
// - ENV_FILE=staging => .env.staging
// - ENV_FILE=任意字符串 => 视为显式文件名或路径
if let Ok(v) = std::env::var("ENV_FILE") {
let env_file_used: Option<String> = if let Ok(v) = std::env::var("ENV_FILE") {
let filename = match v.trim() {
"" => ".env".to_string(),
"prod" | "production" => ".env.prod".to_string(),
@ -30,10 +30,16 @@ async fn main() -> anyhow::Result<()> {
"staging" | "pre" | "preprod" | "pre-production" => ".env.staging".to_string(),
other => other.to_string(),
};
dotenvy::from_filename(&filename).ok();
match dotenvy::from_filename_override(&filename) {
Ok(_) => Some(filename),
Err(_) => Some(format!("{} (not found)", filename)),
}
} else {
dotenvy::dotenv().ok();
}
match dotenvy::dotenv_override() {
Ok(path) => Some(path.to_string_lossy().to_string()),
Err(_) => None,
}
};
tracing_subscriber::fmt().with_env_filter(tracing_subscriber::EnvFilter::from_default_env()).init();
@ -91,7 +97,13 @@ async fn main() -> anyhow::Result<()> {
.layer(cors)
.layer(middleware::from_fn_with_state(db.clone(), middlewares::logging::request_logger));
let addr = format!("{}:{}", std::env::var("APP_HOST").unwrap_or("0.0.0.0".into()), std::env::var("APP_PORT").unwrap_or("8080".into()));
// 读取并记录最终使用的主机与端口(默认端口改为 9898
let app_host = std::env::var("APP_HOST").unwrap_or("0.0.0.0".into());
let app_port = std::env::var("APP_PORT").unwrap_or("9898".into());
if let Some(f) = &env_file_used { tracing::info!("env file loaded: {}", f); } else { tracing::info!("env file loaded: <none>"); }
tracing::info!("resolved APP_HOST={} APP_PORT={}", app_host, app_port);
let addr = format!("{}:{}", app_host, app_port);
tracing::info!("listening on {}", addr);
axum::serve(tokio::net::TcpListener::bind(addr).await?, app).await?;
Ok(())

View File

@ -7,10 +7,17 @@ use sea_orm::ActiveValue::NotSet;
pub struct LoginResult { pub user: user::Model, pub access: String, pub refresh: String }
pub async fn login(db: &Db, username: String, password_plain: String) -> Result<LoginResult, AppError> {
let u = user::Entity::find().filter(user::Column::Username.eq(username.clone())).one(db).await?.ok_or(AppError::Unauthorized)?;
if u.status != 1 { return Err(AppError::Forbidden); }
let ok = password::verify_password(&password_plain, &u.password_hash).map_err(|_| AppError::Unauthorized)?;
if !ok { return Err(AppError::Unauthorized); }
let u = user::Entity::find()
.filter(user::Column::Username.eq(username.clone()))
.one(db)
.await?
.ok_or(AppError::UnauthorizedMsg("用户名或密码错误".into()))?;
if u.status != 1 { return Err(AppError::ForbiddenMsg("账户已禁用".into())); }
let ok = password::verify_password(&password_plain, &u.password_hash)
.map_err(|_| AppError::UnauthorizedMsg("用户名或密码错误".into()))?;
if !ok { return Err(AppError::UnauthorizedMsg("用户名或密码错误".into())); }
let access_claims = crate::middlewares::jwt::new_access_claims(u.id, &u.username);
let refresh_claims = crate::middlewares::jwt::new_refresh_claims(u.id, &u.username);
@ -79,7 +86,7 @@ pub async fn rotate_refresh(db: &Db, uid: i64, old_refresh: String) -> Result<(S
let existing = refresh_token::Entity::find().filter(refresh_token::Column::UserId.eq(uid)).filter(refresh_token::Column::TokenHash.eq(token_hash.clone())).one(db).await?;
if !is_valid_redis && existing.is_none() {
return Err(AppError::Unauthorized);
return Err(AppError::Unauthorized);
}
let u = user::Entity::find_by_id(uid).one(db).await?.ok_or(AppError::Unauthorized)?;

View File

@ -90,7 +90,7 @@ export default function Dashboard() {
return (
<div>
<PageHeader items={["首页"]} title="首页" />
{/* <PageHeader items={["首页"]} title="" /> */}
<Row gutter={[16, 16]}>
<Col xs={12} sm={12} md={6}>
<Card loading={loading}>
@ -163,7 +163,7 @@ export default function Dashboard() {
<Card style={{ marginTop: 16 }}>
<Typography.Paragraph>
使 Admin
使
</Typography.Paragraph>
</Card>
</div>

View File

@ -1,7 +1,7 @@
import { LockOutlined, UserOutlined } from '@ant-design/icons'
import { Button, Card, Form, Input, message, Typography } from 'antd'
import { useNavigate } from 'react-router-dom'
import api from '../utils/axios'
import api, { type ApiResp } from '../utils/axios'
import { setToken, setUser } from '../utils/token'
import './login.css'
@ -11,26 +11,27 @@ export default function Login() {
const onFinish = async (values: any) => {
try {
const { data } = await api.post('/auth/login', values)
const { data } = await api.post<ApiResp<{ access_token: string; user: any }>>('/auth/login', values)
if (data?.code === 0) {
const token = data.data.access_token
const token = data.data!.access_token
setToken(token)
setUser(data.data.user)
setUser(data.data!.user)
message.success('登录成功')
navigate('/', { replace: true })
} else {
throw new Error(data?.message || '登录失败')
}
} catch (e: any) {
message.error(e.message || '登录失败')
const serverMsg = e?.response?.data?.message
message.error(serverMsg || e.message || '登录失败')
}
}
return (
<div className="login-wrap">
<Card className="login-card" variant="outlined">
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 24 }}>Udmin </Typography.Title>
<Form form={form} onFinish={onFinish} layout="vertical" initialValues={{ username: 'admin', password: 'Admin@123' }}>
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 24 }}> </Typography.Title>
<Form form={form} onFinish={onFinish} layout="vertical" initialValues={{ username: 'admin', password: '123456' }}>
<Form.Item name="username" label="用户名" rules={[{ required: true }]}>
<Input size="large" prefix={<UserOutlined />} placeholder="用户名" />
</Form.Item>

View File

@ -33,8 +33,34 @@ api.interceptors.response.use(
(r: AxiosResponse) => r,
async (error: AxiosError<ApiResp<unknown>>) => {
const original = (error.config || {}) as RetryConfig
if (error.response?.status === 401 && !original._retry) {
const status = error.response?.status
if (status === 401) {
const reqUrl = (original?.url || '').toString()
const resp = error.response?.data as ApiResp<unknown> | undefined
// 登录接口返回 401不做 refresh不跳转直接把服务端消息抛给调用方
if (reqUrl.includes('/auth/login')) {
const msg = resp?.message || '用户名或密码错误'
return Promise.reject(new Error(msg))
}
// 已经重试过了,直接拒绝
if (original._retry) {
return Promise.reject(error)
}
original._retry = true
const hasToken = !!getToken()
if (!hasToken) {
// 没有 token 的 401如果不在登录页则跳转到登录页否则仅抛错以便界面提示
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
window.location.href = '/login'
}
const msg = resp?.message || '未登录或登录已过期'
return Promise.reject(new Error(msg))
}
// 有 token 的 401尝试刷新
if (!isRefreshing) {
isRefreshing = true
try {
@ -50,7 +76,7 @@ api.interceptors.response.use(
pendingQueue.forEach(p => p.reject(e))
pendingQueue = []
clearToken()
if (typeof window !== 'undefined') {
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
window.location.href = '/login'
}
return Promise.reject(e)

View File

@ -1,8 +1,8 @@
// 前端系统配置常量
export const APP_CONFIG = {
// 网站标题配置
SITE_NAME: '道友管理系统',
SITE_NAME_SHORT: '道',
SITE_NAME: '道友',
SITE_NAME_SHORT: '道',
// 其他系统配置可以在这里添加
// 如: API_VERSION: 'v1', DEFAULT_PAGE_SIZE: 10 等