diff --git a/frontend/components/common/risk/risk-block-dialog.tsx b/frontend/components/common/risk/risk-block-dialog.tsx index 6306b0f..fdd396b 100644 --- a/frontend/components/common/risk/risk-block-dialog.tsx +++ b/frontend/components/common/risk/risk-block-dialog.tsx @@ -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 - 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() { diff --git a/frontend/components/common/risk/risk-info-box.tsx b/frontend/components/common/risk/risk-info-box.tsx index b2e05df..9e1c147 100644 --- a/frontend/components/common/risk/risk-info-box.tsx +++ b/frontend/components/common/risk/risk-info-box.tsx @@ -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({ @@ -16,19 +23,30 @@ export function RiskInfoBox({ labelClassName?: string }) { return ( -
+
{label} {riskInfo?.risk_level || "未知"}
-
- {riskInfo?.risk_labels.length ? ( - riskInfo.risk_labels.map(label => ( - - {label} - +
+ {riskInfo?.risks.length ? ( + riskInfo.risks.map(risk => ( +
+ {risk.label} + {risk.desc ? ( +

{risk.desc}

+ ) : null} +
)) + ) : riskInfo?.risk_labels.length ? ( +
+ {riskInfo.risk_labels.map(label => ( + + {label} + + ))} +
) : ( 暂无风险标签详情 )} diff --git a/frontend/lib/services/core/api-client.ts b/frontend/lib/services/core/api-client.ts index 30f1262..ff94a7c 100644 --- a/frontend/lib/services/core/api-client.ts +++ b/frontend/lib/services/core/api-client.ts @@ -39,36 +39,77 @@ const pendingRequests = new Map>>(); 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((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, }; } @@ -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, }; } diff --git a/internal/apps/oauth/risk.go b/internal/apps/oauth/risk.go index 42f87a9..5358a15 100644 --- a/internal/apps/oauth/risk.go +++ b/internal/apps/oauth/risk.go @@ -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" @@ -49,6 +50,7 @@ const ( type openAPIUserRiskItem struct { Label string `json:"label"` Value string `json:"value"` + Desc string `json:"desc"` } type openAPIUserRiskResponse struct { @@ -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) { @@ -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) { @@ -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 {