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 @@ -133,6 +133,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 @@ -5,6 +5,7 @@ import { ThemeProvider } from "@/components/layout/theme-provider";
import { CustomThemeProvider } from "@/lib/theme";
import { BellRingProvider } from "@/contexts/bell-ring-context";
import { NotificationSettingsProvider } from "@/contexts/notification-settings-context";
import { RiskBlockDialog } from "@/components/common/risk/risk-block-dialog";
import "./globals.css";

const inter = Inter({
Expand Down Expand Up @@ -54,6 +55,7 @@ export default function RootLayout({
<NotificationSettingsProvider>
<BellRingProvider>
{children}
<RiskBlockDialog />
<Toaster position="top-center" />
</BellRingProvider>
</NotificationSettingsProvider>
Expand Down
59 changes: 59 additions & 0 deletions frontend/components/common/risk/risk-block-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"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="sm:max-w-md"
>
<DialogHeader className="items-center 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>
)
}
38 changes: 38 additions & 0 deletions frontend/components/common/risk/risk-info-box.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"

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>
)
}
20 changes: 20 additions & 0 deletions frontend/components/common/risk/risk-warning-toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
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,
},
)
}
77 changes: 76 additions & 1 deletion frontend/lib/services/core/api-client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import axios, { AxiosError, AxiosResponse, CancelTokenSource, InternalAxiosRequestConfig } from 'axios';
import { toast } from 'sonner';
import { showRiskWarningToast } from '@/components/common/risk/risk-warning-toast';
import { apiConfig } from './config';
import {
ApiErrorBase,
Expand Down Expand Up @@ -36,6 +37,63 @@ const cancelTokens = new Map<string, CancelTokenSource>();
*/
const pendingRequests = new Map<string, Promise<AxiosResponse<ApiResponse>>>();

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[];
}

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 = headers[RISK_LEVEL_HEADER];
if (typeof riskLevel !== 'string' || !riskLevel) return null;

const riskLabelsHeader = headers[RISK_LABELS_HEADER];
return {
risk_level: riskLevel,
risk_labels: typeof riskLabelsHeader === 'string' ? decodeRiskLabels(riskLabelsHeader) : [],
};
}

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 showRiskWarning(riskInfo: RiskInfo): void {
showRiskWarningToast(riskInfo);
}

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

/**
* 生成请求的唯一键
* 包含方法、URL 和请求数据的哈希,确保不同参数的请求不会被误取消
Expand Down Expand Up @@ -99,6 +157,12 @@ apiClient.interceptors.response.use(
const requestKey = getRequestKey(response.config);
cancelTokens.delete(requestKey);
pendingRequests.delete(requestKey);

const riskInfo = riskInfoFromHeaders(response.headers);
if (riskInfo) {
showRiskWarning(riskInfo);
}

return response;
},
(error: AxiosError<ApiError>) => {
Expand All @@ -122,8 +186,19 @@ apiClient.interceptors.response.use(

/* 403 权限不足错误 */
if (error.response?.status === 403) {
if (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 ForbiddenError(error.response.data?.error_msg || '账号存在风险', RISK_BLOCKED_CODE, error.response.data?.details),
);
}

return Promise.reject(
new ForbiddenError(error.response.data?.error_msg || '权限不足,请过盾后重试'),
new ForbiddenError(error.response.data?.error_msg || '权限不足,请过盾后重试', error.response.data?.error_code, error.response.data?.details),
);
}

Expand Down
5 changes: 2 additions & 3 deletions frontend/lib/services/core/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ export class UnauthorizedError extends ApiErrorBase {
* 权限不足错误 (403)
*/
export class ForbiddenError extends ApiErrorBase {
constructor(message = '权限不足') {
super(message, 'FORBIDDEN', 403);
constructor(message = '权限不足', code = 'FORBIDDEN', details?: unknown) {
super(message, code, 403, details);
this.name = 'ForbiddenError';
Object.setPrototypeOf(this, ForbiddenError.prototype);
}
Expand Down Expand Up @@ -103,4 +103,3 @@ export class ValidationError extends ApiErrorBase {
export function isCancelError(error: unknown): boolean {
return error !== null && typeof error === 'object' && ('__CANCEL__' in error && (error as { __CANCEL__?: boolean }).__CANCEL__ === true || ('message' in error && (error as { message?: string }).message === '请求已被取消'));
}

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

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

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