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
3 changes: 2 additions & 1 deletion frontend/components/common/risk/risk-block-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ function isRiskInfo(value: unknown): value is RiskInfo {
const riskInfo = value as Partial<RiskInfo>;
return (
typeof riskInfo.risk_level === 'string' &&
Array.isArray(riskInfo.risk_labels)
Array.isArray(riskInfo.risk_labels) &&
Array.isArray(riskInfo.risks)
);
}

Expand Down
39 changes: 32 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 {Badge} from '@/components/ui/badge';
import {cn} from '@/lib/utils';

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,7 +23,7 @@ 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(
Expand All @@ -29,13 +36,31 @@ export function RiskInfoBox({
<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">
暂无风险标签详情
Expand Down
6 changes: 6 additions & 0 deletions frontend/components/common/risk/risk-warning-toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ export function showRiskWarningToast(riskInfo: RiskInfo) {
id: RISK_TOAST_ID,
duration: Infinity,
dismissible: true,
style: {
background: 'transparent',
border: 'none',
boxShadow: 'none',
padding: 0,
},
onDismiss: () => {
isRiskWarningDismissed = true;
},
Expand Down
74 changes: 65 additions & 9 deletions frontend/lib/services/core/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,20 @@ const apiClient = axios.create({

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

class ApiClientError extends Error {
Expand Down Expand Up @@ -59,31 +67,75 @@ function getHeader(
return typeof directValue === 'string' ? directValue : undefined;
}

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 = getHeader(headers, RISK_LEVEL_HEADER);
if (!riskLevel) return null;

const risks = normalizeRiskItems(
decodeBase64JSON(getHeader(headers, RISK_ITEMS_HEADER)),
);
const labels = normalizeRiskLabels(
decodeBase64JSON(getHeader(headers, RISK_LABELS_HEADER)),
);

return {
risk_level: riskLevel,
risk_labels: decodeRiskLabels(getHeader(headers, RISK_LABELS_HEADER)),
risk_labels: labels.length ? labels : riskLabelsFromItems(risks),
risks,
};
}

Expand All @@ -98,13 +150,17 @@ function riskInfoFromDetails(details: unknown): RiskInfo | null {
'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 @@ -45,6 +45,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 @@ -54,6 +55,7 @@ const (
type openAPIUserRiskItem struct {
Label string `json:"label"`
Value string `json:"value"`
Desc string `json:"desc"`
}

type openAPIUserRiskResponse struct {
Expand All @@ -63,8 +65,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 @@ -157,37 +160,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 @@ -228,6 +240,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