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
2 changes: 1 addition & 1 deletion frontend/components/common/risk/risk-block-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ 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)
return typeof riskInfo.risk_level === "string" && Array.isArray(riskInfo.risk_labels) && Array.isArray(riskInfo.risks)
}

export function RiskBlockDialog() {
Expand Down
32 changes: 25 additions & 7 deletions frontend/components/common/risk/risk-info-box.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"

export interface RiskItem {
label: string
value?: string
desc?: string
}

export interface RiskInfo {
risk_level: string
risk_labels: string[]
risks: RiskItem[]
}

export function RiskInfoBox({
Expand All @@ -16,19 +23,30 @@ export function RiskInfoBox({
labelClassName?: string
}) {
return (
<div className="rounded-lg border bg-card p-4">
<div className="rounded-lg border bg-card p-4 shadow-sm">
<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>
<div className="mt-4 space-y-2">
{riskInfo?.risks.length ? (
riskInfo.risks.map(risk => (
<div key={risk.value || risk.label} className="rounded-md border bg-muted/35 px-3 py-2.5">
<span className="text-sm font-medium leading-5 text-foreground">{risk.label}</span>
{risk.desc ? (
<p className="mt-1 text-xs leading-5 text-muted-foreground">{risk.desc}</p>
) : null}
</div>
))
) : riskInfo?.risk_labels.length ? (
<div className="flex flex-wrap gap-2">
{riskInfo.risk_labels.map(label => (
<Badge key={label} variant="secondary">
{label}
</Badge>
))}
</div>
) : (
<span className="text-sm text-muted-foreground">暂无风险标签详情</span>
)}
Expand Down
56 changes: 51 additions & 5 deletions frontend/lib/services/core/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,36 +39,77 @@ 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_ITEMS_HEADER = 'x-credit-risks';
const RISK_BLOCKED_CODE = 'RISK_BLOCKED';
const RISK_BLOCKED_EVENT = 'credit-risk-blocked';

interface RiskItem {
label: string;
value?: string;
desc?: string;
}

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

function decodeRiskLabels(value?: string): string[] {
function decodeBase64JSON(value?: string): unknown {
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') : [];
return JSON.parse(json);
} catch {
return [];
}
}

function normalizeRiskItems(value: unknown): RiskItem[] {
if (!Array.isArray(value)) return [];

return value.reduce<RiskItem[]>((items, item) => {
if (!item || typeof item !== 'object') return items;

const label = 'label' in item ? (item as { label?: unknown }).label : undefined;
if (typeof label !== 'string' || !label.trim()) return items;

const value = 'value' in item ? (item as { value?: unknown }).value : undefined;
const desc = 'desc' in item ? (item as { desc?: unknown }).desc : undefined;
items.push({
label: label.trim(),
value: typeof value === 'string' ? value.trim() : undefined,
desc: typeof desc === 'string' ? desc.trim() : undefined,
});

return items;
}, []);
}

function normalizeRiskLabels(value: unknown): string[] {
return Array.isArray(value) ? value.filter((label): label is string => typeof label === 'string' && !!label.trim()).map(label => label.trim()) : [];
}

function riskLabelsFromItems(items: RiskItem[]): string[] {
return items.map(item => item.label).filter(Boolean);
}

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];
const riskItemsHeader = headers[RISK_ITEMS_HEADER];
const risks = typeof riskItemsHeader === 'string' ? normalizeRiskItems(decodeBase64JSON(riskItemsHeader)) : [];
const labels = typeof riskLabelsHeader === 'string' ? normalizeRiskLabels(decodeBase64JSON(riskLabelsHeader)) : riskLabelsFromItems(risks);

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

Expand All @@ -77,11 +118,16 @@ function riskInfoFromDetails(details: unknown): RiskInfo | 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;
const riskItems = 'risks' in details ? (details as { risks?: unknown }).risks : undefined;
if (typeof riskLevel !== 'string' || !riskLevel) return null;

const risks = normalizeRiskItems(riskItems);
const labels = normalizeRiskLabels(riskLabels);

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

Expand Down
38 changes: 32 additions & 6 deletions internal/apps/oauth/risk.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const (

riskLevelHeader = "X-Credit-Risk-Level"
riskLabelsHeader = "X-Credit-Risk-Labels"
riskItemsHeader = "X-Credit-Risks"
exposeHeader = "Access-Control-Expose-Headers"

riskBlockedCode = "RISK_BLOCKED"
Expand All @@ -49,6 +50,7 @@ const (
type openAPIUserRiskItem struct {
Label string `json:"label"`
Value string `json:"value"`
Desc string `json:"desc"`
}

type openAPIUserRiskResponse struct {
Expand All @@ -58,8 +60,9 @@ type openAPIUserRiskResponse struct {
}

type riskBlockDetails struct {
RiskLevel string `json:"risk_level"`
RiskLabels []string `json:"risk_labels"`
RiskLevel string `json:"risk_level"`
RiskLabels []string `json:"risk_labels"`
Risks []openAPIUserRiskItem `json:"risks"`
}

func checkOpenAPIUserRisk(ctx context.Context, userID uint64) (*openAPIUserRiskResponse, bool) {
Expand Down Expand Up @@ -143,37 +146,46 @@ func applyOpenAPIUserRisk(c *gin.Context, risk *openAPIUserRiskResponse) bool {
}

labels := riskLabels(risk)
items := riskItems(risk)
cfg := config.Config.OpenAPIRisk
if containsString(cfg.BlockRiskLevels, risk.RiskLevel) {
setRiskHeaders(c, risk.RiskLevel, labels)
setRiskHeaders(c, risk.RiskLevel, labels, items)
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error_code": riskBlockedCode,
"error_msg": riskBlockedMsg,
"details": riskBlockDetails{
RiskLevel: risk.RiskLevel,
RiskLabels: labels,
Risks: items,
},
})
return true
}

if containsString(cfg.PromptRiskLevels, risk.RiskLevel) {
setRiskHeaders(c, risk.RiskLevel, labels)
setRiskHeaders(c, risk.RiskLevel, labels, items)
}

return false
}

func setRiskHeaders(c *gin.Context, riskLevel string, labels []string) {
func setRiskHeaders(c *gin.Context, riskLevel string, labels []string, items []openAPIUserRiskItem) {
labelsJSON, err := json.Marshal(labels)
if err != nil {
logger.ErrorF(c.Request.Context(), "[OpenAPIRisk] marshal risk labels failed: %v", err)
return
}

itemsJSON, err := json.Marshal(items)
if err != nil {
logger.ErrorF(c.Request.Context(), "[OpenAPIRisk] marshal risk items failed: %v", err)
return
}

c.Header(riskLevelHeader, riskLevel)
c.Header(riskLabelsHeader, base64.StdEncoding.EncodeToString(labelsJSON))
appendExposeHeaders(c, riskLevelHeader, riskLabelsHeader)
c.Header(riskItemsHeader, base64.StdEncoding.EncodeToString(itemsJSON))
appendExposeHeaders(c, riskLevelHeader, riskLabelsHeader, riskItemsHeader)
}

func appendExposeHeaders(c *gin.Context, names ...string) {
Expand Down Expand Up @@ -214,6 +226,20 @@ func riskLabels(risk *openAPIUserRiskResponse) []string {
return labels
}

func riskItems(risk *openAPIUserRiskResponse) []openAPIUserRiskItem {
items := make([]openAPIUserRiskItem, 0, len(risk.Risks))
for _, item := range risk.Risks {
item.Label = strings.TrimSpace(item.Label)
item.Value = strings.TrimSpace(item.Value)
item.Desc = strings.TrimSpace(item.Desc)
if item.Label == "" {
continue
}
items = append(items, item)
}
return items
}

func containsString(values []string, target string) bool {
target = strings.TrimSpace(target)
for _, value := range values {
Expand Down
Loading