Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,16 @@ worker:
linuxDo:
api_key: "<LINUX_DO_API_KEY>"

# OpenAPI Risk
openapi_risk:
enabled: false
base_url: "https://audit.example.com"
username: "<OPENAPI_USERNAME>"
password: "<OPENAPI_PASSWORD>"
cache_ttl_seconds: 3600
prompt_risk_levels: []
block_risk_levels: []

# OpenTelemetry 配置
otel:
sampling_rate: 0.1 # 采样率 0.0-1.0
Expand Down
2 changes: 2 additions & 0 deletions frontend/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {Metadata} from 'next';
import {Inter, Noto_Sans_SC} from 'next/font/google';
import {Toaster} from '@/components/ui/sonner';
import {ThemeProvider} from '@/components/common/layout/ThemeProvider';
import {RiskBlockDialog} from '@/components/common/risk/risk-block-dialog';
import './globals.css';

// eslint-disable-next-line new-cap
Expand Down Expand Up @@ -49,6 +50,7 @@ export default function RootLayout({
disableTransitionOnChange
>
{children}
<RiskBlockDialog />
<Toaster />
</ThemeProvider>
</body>
Expand Down
65 changes: 65 additions & 0 deletions frontend/components/common/risk/risk-block-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
'use client';

import {useEffect, useState} from 'react';
import {AlertTriangle} from 'lucide-react';
import {RiskInfo, RiskInfoBox} from '@/components/common/risk/risk-info-box';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';

const RISK_BLOCKED_EVENT = 'credit-risk-blocked';

function isRiskInfo(value: unknown): value is RiskInfo {
if (!value || typeof value !== 'object') return false;
const riskInfo = value as Partial<RiskInfo>;
return (
typeof riskInfo.risk_level === 'string' &&
Array.isArray(riskInfo.risk_labels)
);
}

export function RiskBlockDialog() {
const [riskInfo, setRiskInfo] = useState<RiskInfo | null>(null);

useEffect(() => {
const handleRiskBlocked = (event: Event) => {
const detail = (event as CustomEvent<unknown>).detail;
if (!isRiskInfo(detail)) return;
setRiskInfo(detail);
};

window.addEventListener(RISK_BLOCKED_EVENT, handleRiskBlocked);
return () =>
window.removeEventListener(RISK_BLOCKED_EVENT, handleRiskBlocked);
}, []);

return (
<Dialog open={!!riskInfo}>
<DialogContent
showCloseButton={false}
onEscapeKeyDown={(event) => event.preventDefault()}
onPointerDownOutside={(event) => event.preventDefault()}
onInteractOutside={(event) => event.preventDefault()}
className="gap-4 p-6 sm:max-w-md"
>
<DialogHeader className="items-center px-0 pt-0 text-center sm:text-center">
<div className="mb-1 flex size-12 items-center justify-center rounded-full bg-destructive/10">
<AlertTriangle className="size-6 text-destructive" />
</div>
<DialogTitle className="text-center text-lg">
账号存在风险
</DialogTitle>
<DialogDescription className="text-center text-sm leading-6">
因触发风控,账号暂时无法使用需登录的功能
</DialogDescription>
</DialogHeader>

<RiskInfoBox riskInfo={riskInfo} />
</DialogContent>
</Dialog>
);
}
47 changes: 47 additions & 0 deletions frontend/components/common/risk/risk-info-box.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {Badge} from '@/components/ui/badge';
import {cn} from '@/lib/utils';

export interface RiskInfo {
risk_level: string;
risk_labels: string[];
}

export function RiskInfoBox({
riskInfo,
label = '风险等级',
labelClassName,
}: {
riskInfo: RiskInfo | null;
label?: string;
labelClassName?: string;
}) {
return (
<div className="rounded-lg border bg-card p-4">
<div className="flex flex-wrap items-center gap-2">
<span
className={cn(
'text-sm font-medium text-muted-foreground',
labelClassName,
)}
>
{label}
</span>
<Badge variant="destructive">{riskInfo?.risk_level || '未知'}</Badge>
</div>

<div className="mt-4 flex flex-wrap gap-2">
{riskInfo?.risk_labels.length ? (
riskInfo.risk_labels.map((label) => (
<Badge key={label} variant="secondary">
{label}
</Badge>
))
) : (
<span className="text-sm text-muted-foreground">
暂无风险标签详情
</span>
)}
</div>
</div>
);
}
26 changes: 26 additions & 0 deletions frontend/components/common/risk/risk-warning-toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use client';

import {toast} from 'sonner';
import {RiskInfo, RiskInfoBox} from '@/components/common/risk/risk-info-box';

const RISK_TOAST_ID = 'credit-risk-warning';

export function showRiskWarningToast(riskInfo: RiskInfo) {
toast.custom(
() => (
<div className="w-[min(calc(100vw-2rem),28rem)]">
<RiskInfoBox
riskInfo={riskInfo}
label="账号存在风险提示"
labelClassName="font-semibold text-foreground"
/>
</div>
),
{
id: RISK_TOAST_ID,
duration: Infinity,
closeButton: false,
dismissible: false,
},
);
}
149 changes: 144 additions & 5 deletions frontend/lib/services/core/api-client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import axios, {AxiosError, AxiosResponse} from 'axios';
import {showRiskWarningToast} from '@/components/common/risk/risk-warning-toast';
import {ApiError, ApiResponse} from './types';

/**
Expand All @@ -13,6 +14,107 @@ const apiClient = axios.create({
},
});

const RISK_LEVEL_HEADER = 'x-credit-risk-level';
const RISK_LABELS_HEADER = 'x-credit-risk-labels';
const RISK_BLOCKED_CODE = 'RISK_BLOCKED';
const RISK_BLOCKED_EVENT = 'credit-risk-blocked';

interface RiskInfo {
risk_level: string;
risk_labels: string[];
}

class ApiClientError extends Error {
code?: string;
details?: unknown;
status?: number;

constructor(
message: string,
code?: string,
details?: unknown,
status?: number,
) {
super(message);
this.name = 'ApiError';
this.code = code;
this.details = details;
this.status = status;
}
}

function getHeader(
headers: AxiosResponse['headers'],
name: string,
): string | undefined {
const maybeAxiosHeaders = headers as { get?: (key: string) => unknown };
const value = maybeAxiosHeaders.get?.(name);
if (typeof value === 'string') return value;

const plainHeaders = headers as Record<string, unknown>;
const lowerValue = plainHeaders[name.toLowerCase()];
if (typeof lowerValue === 'string') return lowerValue;

const directValue = plainHeaders[name];
return typeof directValue === 'string' ? directValue : undefined;
}

function decodeRiskLabels(value?: string): string[] {
if (!value || typeof window === 'undefined') return [];

try {
const binary = window.atob(value);
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
const json = new TextDecoder().decode(bytes);
const labels = JSON.parse(json);
return Array.isArray(labels) ?
labels.filter((label): label is string => typeof label === 'string') :
[];
} catch {
return [];
}
}

function riskInfoFromHeaders(
headers: AxiosResponse['headers'],
): RiskInfo | null {
const riskLevel = getHeader(headers, RISK_LEVEL_HEADER);
if (!riskLevel) return null;

return {
risk_level: riskLevel,
risk_labels: decodeRiskLabels(getHeader(headers, RISK_LABELS_HEADER)),
};
}

function riskInfoFromDetails(details: unknown): RiskInfo | null {
if (!details || typeof details !== 'object') return null;

const riskLevel =
'risk_level' in details ?
(details as { risk_level?: unknown }).risk_level :
undefined;
const riskLabels =
'risk_labels' in details ?
(details as { risk_labels?: unknown }).risk_labels :
undefined;
if (typeof riskLevel !== 'string' || !riskLevel) return null;

return {
risk_level: riskLevel,
risk_labels: Array.isArray(riskLabels) ?
riskLabels.filter((label): label is string => typeof label === 'string') :
[],
};
}

function showRiskBlockedDialog(riskInfo: RiskInfo): void {
if (typeof window === 'undefined') return;
window.dispatchEvent(
new CustomEvent<RiskInfo>(RISK_BLOCKED_EVENT, {detail: riskInfo}),
);
}

/**
* 请求拦截器
* 确保所有请求带上凭证
Expand All @@ -31,7 +133,10 @@ apiClient.interceptors.request.use(
*/
function initiateLogin(currentPath: string): Promise<never> {
// 防止循环重定向
if (!currentPath.startsWith('/login') && !currentPath.startsWith('/callback')) {
if (
!currentPath.startsWith('/login') &&
!currentPath.startsWith('/callback')
) {
// 动态导入AuthService避免循环依赖
import('../auth/auth.service').then(({AuthService}) => {
// 直接调用登录方法,传入当前路径作为重定向目标
Expand All @@ -48,17 +153,49 @@ function initiateLogin(currentPath: string): Promise<never> {
* 处理API响应和统一错误处理
*/
apiClient.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => response,
(response: AxiosResponse<ApiResponse>) => {
const riskInfo = riskInfoFromHeaders(response.headers);
if (riskInfo) {
showRiskWarningToast(riskInfo);
}
return response;
},
(error: AxiosError<ApiError>) => {
// 处理401未授权错误
if (error.response?.status === 401) {
return initiateLogin(window.location.pathname);
}

// 处理风控阻断
if (
error.response?.status === 403 &&
error.response.data?.error_code === RISK_BLOCKED_CODE
) {
const riskInfo =
riskInfoFromDetails(error.response.data.details) ||
riskInfoFromHeaders(error.response.headers);
if (riskInfo) {
showRiskBlockedDialog(riskInfo);
}

return Promise.reject(
new ApiClientError(
error.response.data?.error_msg || '账号存在风险',
RISK_BLOCKED_CODE,
error.response.data?.details,
403,
),
);
}

// 处理后端返回的错误信息
if (error.response?.data?.error_msg) {
const apiError = new Error(error.response.data.error_msg);
apiError.name = 'ApiError';
const apiError = new ApiClientError(
error.response.data.error_msg,
error.response.data.error_code,
error.response.data.details,
error.response.status,
);
return Promise.reject(apiError);
}

Expand All @@ -69,7 +206,9 @@ apiClient.interceptors.response.use(

// 处理权限错误
if (error.response?.status === 403) {
return Promise.reject(new Error('权限不足'));
return Promise.reject(
new ApiClientError('权限不足', 'FORBIDDEN', undefined, 403),
);
}

// 处理服务器错误
Expand Down
4 changes: 4 additions & 0 deletions frontend/lib/services/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export interface PaginatedResponse<T = unknown> {
export interface ApiError {
/** 错误信息 */
error_msg: string;
/** 错误码 */
error_code?: string;
/** 错误详情 */
details?: unknown;
}

/**
Expand Down
6 changes: 6 additions & 0 deletions internal/apps/oauth/middlewares.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ func LoginRequired() gin.HandlerFunc {
// set user info
SetUserToContext(c, &user)

if risk, ok := checkOpenAPIUserRisk(ctx, user.ID); ok {
if blocked := applyOpenAPIUserRisk(c, risk); blocked {
return
}
}

// next
c.Next()
}
Expand Down
Loading
Loading