feat: 认证相关

This commit is contained in:
2025-09-06 19:56:13 +08:00
parent 5a25f350c7
commit cd99485c9a
13 changed files with 148 additions and 45 deletions

View File

@@ -63,6 +63,11 @@ export class AuthController {
} }
return ctx.session.user; return ctx.session.user;
} }
@Get('info')
async info(ctx: Context) {
return ctx.session?.user;
}
} }
interface LoginRequestBody { interface LoginRequestBody {

View File

@@ -1,7 +1,7 @@
import type Koa from 'koa'; import type Koa from 'koa';
import KoaRouter from '@koa/router'; import KoaRouter from '@koa/router';
import { getRouteMetadata, getControllerPrefix, type RouteMetadata } from '../decorators/route.ts'; import { getRouteMetadata, getControllerPrefix, type RouteMetadata } from '../decorators/route.ts';
import { createAutoSuccessResponse } from '../middlewares/exception.ts'; import { createSuccessResponse } from '../middlewares/exception.ts';
/** /**
* 控制器类型 * 控制器类型
@@ -102,22 +102,16 @@ export class RouteScanner {
*/ */
private wrapControllerMethod(instance: any, methodName: string) { private wrapControllerMethod(instance: any, methodName: string) {
return async (ctx: Koa.Context, next: Koa.Next) => { return async (ctx: Koa.Context, next: Koa.Next) => {
try { // 调用控制器方法
// 调用控制器方法 const method = instance[methodName];
const method = instance[methodName]; if (typeof method !== 'function') {
if (typeof method !== 'function') { ctx.throw(401, 'Not Found')
throw new Error(`控制器方法 ${methodName} 不存在或不是函数`);
}
// 绑定this并调用方法
const result = await method.call(instance, ctx, next);
// 如果控制器返回了数据,则包装成统一响应格式
ctx.body = createAutoSuccessResponse(result);
} catch (error) {
// 错误由全局异常处理中间件处理
throw error;
} }
// 绑定this并调用方法
const result = await method.call(instance, ctx, next) ?? null;
ctx.body = createSuccessResponse(result);
}; };
} }

View File

@@ -0,0 +1,23 @@
import type Koa from 'koa';
import type { Middleware } from './types.ts';
export class Authorization implements Middleware {
private readonly ignoreAuth = [
'/api/auth/login',
'/api/auth/info',
'/api/auth/url',
];
apply(app: Koa) {
app.use(async (ctx: Koa.Context, next: Koa.Next) => {
console.log('ctx.path', ctx.path)
if (this.ignoreAuth.includes(ctx.path)) {
return next();
}
if (ctx.session.isNew) {
ctx.throw(401, 'Unauthorized');
}
await next();
});
}
}

View File

@@ -122,7 +122,7 @@ export class Exception implements Middleware {
*/ */
export function createSuccessResponse<T>( export function createSuccessResponse<T>(
data: T, data: T,
message = '操作成功', message = 'success',
): ApiResponse<T> { ): ApiResponse<T> {
return { return {
code: 0, code: 0,
@@ -132,24 +132,6 @@ export function createSuccessResponse<T>(
}; };
} }
/**
* 创建成功响应的辅助函数(自动设置消息)
*/
export function createAutoSuccessResponse<T>(data: T): ApiResponse<T> {
let message = '操作成功';
// 根据数据类型自动生成消息
if (Array.isArray(data)) {
message = `获取列表成功,共${data.length}条记录`;
} else if (data && typeof data === 'object') {
message = '获取数据成功';
} else if (data === null || data === undefined) {
message = '操作完成';
}
return createSuccessResponse(data, message);
}
/** /**
* 创建失败响应的辅助函数 * 创建失败响应的辅助函数
*/ */

View File

@@ -5,6 +5,7 @@ import { Session } from './session.ts';
import { CORS } from './cors.ts'; import { CORS } from './cors.ts';
import { HttpLogger } from './logger.ts'; import { HttpLogger } from './logger.ts';
import type Koa from 'koa'; import type Koa from 'koa';
import { Authorization } from './Authorization.ts';
/** /**
* 初始化中间件 * 初始化中间件
@@ -20,10 +21,12 @@ export function initMiddlewares(app: Koa) {
// Session 中间件需要在请求体解析之前注册 // Session 中间件需要在请求体解析之前注册
new Session().apply(app); new Session().apply(app);
new CORS().apply(app);
new Authorization().apply(app);
// 请求体解析中间件 // 请求体解析中间件
new BodyParser().apply(app); new BodyParser().apply(app);
new CORS().apply(app);
new Router().apply(app); new Router().apply(app);
} }

Binary file not shown.

View File

@@ -15,7 +15,8 @@
"axios": "^1.11.0", "axios": "^1.11.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router": "^7.8.0" "react-router": "^7.8.0",
"zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@arco-plugins/unplugin-react": "2.0.0-beta.5", "@arco-plugins/unplugin-react": "2.0.0-beta.5",

View File

@@ -1,11 +1,14 @@
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from '@pages/App'; import App from '@pages/App';
import { BrowserRouter } from 'react-router'; import { BrowserRouter } from 'react-router';
import { useGlobalStore } from './stores/global';
const rootEl = document.getElementById('root'); const rootEl = document.getElementById('root');
if (rootEl) { if (rootEl) {
const root = ReactDOM.createRoot(rootEl); const root = ReactDOM.createRoot(rootEl);
useGlobalStore.getState().refreshUser();
root.render( root.render(
<BrowserRouter> <BrowserRouter>
<App /> <App />

View File

@@ -1,17 +1,19 @@
import { Avatar, Layout, Menu } from '@arco-design/web-react'; import { Avatar, Dropdown, Layout, Menu } from '@arco-design/web-react';
import { import {
IconApps, IconApps,
IconExport,
IconMenuFold, IconMenuFold,
IconMenuUnfold, IconMenuUnfold,
IconRobot, IconRobot,
IconUser,
} from '@arco-design/web-react/icon'; } from '@arco-design/web-react/icon';
import { useState } from 'react'; import { useState } from 'react';
import Logo from '@assets/images/logo.svg?react'; import Logo from '@assets/images/logo.svg?react';
import { Link, Outlet } from 'react-router'; import { Link, Outlet } from 'react-router';
import { useGlobalStore } from '../../stores/global';
function Home() { function Home() {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const globalStore = useGlobalStore();
return ( return (
<Layout className="h-screen w-full"> <Layout className="h-screen w-full">
@@ -53,12 +55,32 @@ function Home() {
<Layout> <Layout>
<Layout.Header className="h-14 border-b-gray-100 border-b-[1px]"> <Layout.Header className="h-14 border-b-gray-100 border-b-[1px]">
<div className="flex items-center justify-end px-4 h-full"> <div className="flex items-center justify-end px-4 h-full">
<Avatar> <Dropdown
<IconUser /> trigger={'click'}
</Avatar> droplist={
<Menu className="px-3">
<Menu.Item key="1">
<IconExport />
<span className="ml-2">退</span>
</Menu.Item>
</Menu>
}
>
<div className="p-2 rounded-xl cursor-pointer flex items-center hover:bg-[rgba(0,0,0,0.03)]">
<Avatar size={28}>
<img
alt="avatar"
src={globalStore.user?.avatar_url.replace('https', 'http')}
/>
</Avatar>
<span className="ml-2 font-semibold text-gray-500">
{globalStore.user?.username}
</span>
</div>
</Dropdown>
</div> </div>
</Layout.Header> </Layout.Header>
<Layout.Content> <Layout.Content className="overflow-y-auto bg-gray-100">
<Outlet /> <Outlet />
</Layout.Content> </Layout.Content>
</Layout> </Layout>

View File

@@ -61,7 +61,7 @@ function ProjectPage() {
}; };
return ( return (
<div className="p-6 bg-gray-100 min-h-screen"> <div className="p-6">
<div className="mb-6 flex items-center justify-between"> <div className="mb-6 flex items-center justify-between">
<div> <div>
<Typography.Title heading={2} className="!m-0 !text-gray-900"> <Typography.Title heading={2} className="!m-0 !text-gray-900">

View File

@@ -8,6 +8,24 @@ class Net {
timeout: 20000, timeout: 20000,
withCredentials: true, withCredentials: true,
}); });
this.applyInterceptors(this.instance);
}
private applyInterceptors(instance: Axios) {
instance.interceptors.response.use(
(response) => {
return response;
},
(error) => {
console.log('error', error)
if (error.status === 401 && error.config.url !== '/api/auth/info') {
window.location.href = '/login';
return;
}
return Promise.reject(error);
},
);
} }
async request<T>(config: AxiosRequestConfig): Promise<T> { async request<T>(config: AxiosRequestConfig): Promise<T> {

View File

@@ -0,0 +1,26 @@
import { net, type APIResponse } from '@shared';
import { create } from 'zustand';
interface User {
id: string;
username: string;
email: string;
avatar_url: string;
active: boolean;
}
interface GlobalStore {
user: User | null;
refreshUser: () => Promise<void>;
}
export const useGlobalStore = create<GlobalStore>((set) => ({
user: null,
async refreshUser() {
const { data } = await net.request<APIResponse<User>>({
method: 'GET',
url: '/api/auth/info',
});
set({ user: data });
},
}));

26
pnpm-lock.yaml generated
View File

@@ -90,6 +90,9 @@ importers:
react-router: react-router:
specifier: ^7.8.0 specifier: ^7.8.0
version: 7.8.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 7.8.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
zustand:
specifier: ^5.0.8
version: 5.0.8(@types/react@19.1.12)(react@18.3.1)
devDependencies: devDependencies:
'@arco-plugins/unplugin-react': '@arco-plugins/unplugin-react':
specifier: 2.0.0-beta.5 specifier: 2.0.0-beta.5
@@ -1915,6 +1918,24 @@ packages:
zod@4.1.5: zod@4.1.5:
resolution: {integrity: sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg==} resolution: {integrity: sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg==}
zustand@5.0.8:
resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
immer: '>=9.0.6'
react: '>=18.0.0'
use-sync-external-store: '>=1.2.0'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
snapshots: snapshots:
'@alloc/quick-lru@5.2.0': {} '@alloc/quick-lru@5.2.0': {}
@@ -3664,3 +3685,8 @@ snapshots:
zod@3.25.76: {} zod@3.25.76: {}
zod@4.1.5: {} zod@4.1.5: {}
zustand@5.0.8(@types/react@19.1.12)(react@18.3.1):
optionalDependencies:
'@types/react': 19.1.12
react: 18.3.1