feat: polish internal value displays

This commit is contained in:
JetSprow
2026-04-30 16:38:38 +10:00
parent abc2d4aa72
commit 042c5b34ab
25 changed files with 498 additions and 163 deletions

View File

@@ -5,6 +5,7 @@ import { requireAdmin } from "@/lib/require-auth";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { import {
decryptPaymentConfigForUse, decryptPaymentConfigForUse,
getPaymentProviderName,
normalizePaymentConfig, normalizePaymentConfig,
parsePaymentConfig, parsePaymentConfig,
preparePaymentConfigForStorage, preparePaymentConfigForStorage,
@@ -54,8 +55,8 @@ export async function savePaymentConfig(
action: "payment.config", action: "payment.config",
targetType: "PaymentConfig", targetType: "PaymentConfig",
targetId: provider, targetId: provider,
targetLabel: provider, targetLabel: getPaymentProviderName(provider),
message: `${enabled ? "启用并更新" : "更新"}支付配置 ${provider}`, message: `${enabled ? "启用并更新" : "更新"}支付配置 ${getPaymentProviderName(provider)}`,
}); });
revalidatePath("/admin/payments"); revalidatePath("/admin/payments");
} }

View File

@@ -11,24 +11,15 @@ import { normalizeSiteUrl } from "@/services/site-url";
import { encrypt, isEncryptedValue } from "@/lib/crypto"; import { encrypt, isEncryptedValue } from "@/lib/crypto";
import { getErrorMessage } from "@/lib/errors"; import { getErrorMessage } from "@/lib/errors";
import { sendSmtpTestEmail } from "@/services/email"; import { sendSmtpTestEmail } from "@/services/email";
import {
booleanAppSettingFields,
getBooleanAppSettingLabel,
type BooleanAppSettingField,
} from "@/lib/domain-labels";
const booleanSettingFields = [ const booleanSettingFields = booleanAppSettingFields;
"allowRegistration",
"emailVerificationRequired",
"requireInviteCode",
"autoReminderDispatchEnabled",
"trafficSyncEnabled",
"networkRecommendationsEnabled",
"networkInsightsEnabled",
"subscriptionRiskEnabled",
"subscriptionRiskAutoSuspend",
"nodeAccessRiskEnabled",
"inviteRewardEnabled",
"smtpEnabled",
"smtpSecure",
] as const;
export type BooleanSettingField = (typeof booleanSettingFields)[number]; export type BooleanSettingField = BooleanAppSettingField;
const settingsSchema = z.object({ const settingsSchema = z.object({
siteName: z.string().trim().min(1, "站点名称不能为空"), siteName: z.string().trim().min(1, "站点名称不能为空"),
@@ -363,7 +354,7 @@ export async function saveBooleanAppSetting(input: {
targetType: "AppConfig", targetType: "AppConfig",
targetId: current.id, targetId: current.id,
targetLabel: current.siteName, targetLabel: current.siteName,
message: `${parsed.value ? "开启" : "关闭"}系统开关 ${parsed.field}`, message: `${parsed.value ? "开启" : "关闭"}${getBooleanAppSettingLabel(parsed.field)}开关`,
}); });
revalidateSettingsViews(); revalidateSettingsViews();

View File

@@ -7,6 +7,7 @@ import { dispatchSubscriptionReminders } from "@/services/notifications";
import { confirmPendingOrder } from "@/services/payment/process"; import { confirmPendingOrder } from "@/services/payment/process";
import { runTask, updateTaskRun } from "@/services/task-center"; import { runTask, updateTaskRun } from "@/services/task-center";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getTaskKindLabel } from "@/lib/domain-labels";
function revalidateTaskViews() { function revalidateTaskViews() {
revalidatePath("/admin/tasks"); revalidatePath("/admin/tasks");
@@ -37,7 +38,7 @@ export async function runReminderTask() {
action: "task.run", action: "task.run",
targetType: "TaskRun", targetType: "TaskRun",
targetId: outcome.taskId, targetId: outcome.taskId,
targetLabel: "REMINDER_DISPATCH", targetLabel: getTaskKindLabel("REMINDER_DISPATCH"),
message: "手动执行提醒派发任务", message: "手动执行提醒派发任务",
}); });
@@ -82,7 +83,7 @@ export async function retryTaskRun(taskId: string) {
action: "task.retry", action: "task.retry",
targetType: "TaskRun", targetType: "TaskRun",
targetId: task.id, targetId: task.id,
targetLabel: task.kind, targetLabel: getTaskKindLabel(task.kind),
message: `重试任务 ${task.title}`, message: `重试任务 ${task.title}`,
}); });
} catch (error) { } catch (error) {

View File

@@ -7,6 +7,7 @@ import bcrypt from "bcryptjs";
import { z } from "zod"; import { z } from "zod";
import { actorFromSession, recordAuditLog } from "@/services/audit"; import { actorFromSession, recordAuditLog } from "@/services/audit";
import { createPanelAdapter } from "@/services/node-panel/factory"; import { createPanelAdapter } from "@/services/node-panel/factory";
import { getUserStatusLabel } from "@/lib/domain-labels";
const createUserSchema = z.object({ const createUserSchema = z.object({
email: z.string().email(), email: z.string().email(),
@@ -86,7 +87,7 @@ export async function updateUserStatus(id: string, status: "ACTIVE" | "DISABLED"
targetType: "User", targetType: "User",
targetId: user.id, targetId: user.id,
targetLabel: user.email, targetLabel: user.email,
message: `将用户 ${user.email} 状态改为 ${status}`, message: `将用户 ${user.email} 状态改为${getUserStatusLabel(status)}`,
}); });
revalidatePath("/admin/users"); revalidatePath("/admin/users");
revalidatePath(`/admin/users/${user.id}`); revalidatePath(`/admin/users/${user.id}`);
@@ -291,7 +292,7 @@ export async function batchUpdateUserStatus(formData: FormData) {
actor: actorFromSession(session), actor: actorFromSession(session),
action: "user.batch_status", action: "user.batch_status",
targetType: "User", targetType: "User",
message: `批量更新 ${userIds.length} 个用户状态为 ${status}`, message: `批量更新 ${userIds.length} 个用户状态为${getUserStatusLabel(String(status))}`,
metadata: { metadata: {
userIds, userIds,
status: String(status), status: String(status),

View File

@@ -6,6 +6,7 @@ import { requireAuth } from "@/lib/require-auth";
import { buildUnavailableMessage, getPlanAvailability } from "@/services/plan-availability"; import { buildUnavailableMessage, getPlanAvailability } from "@/services/plan-availability";
import { getPlanPurchasePrice, calculateCheckoutDiscounts } from "@/services/commerce"; import { getPlanPurchasePrice, calculateCheckoutDiscounts } from "@/services/commerce";
import { ensurePlanTrafficPoolCapacity } from "@/services/plan-traffic-pool"; import { ensurePlanTrafficPoolCapacity } from "@/services/plan-traffic-pool";
import { getSubscriptionTypeLabel } from "@/lib/domain-labels";
async function assertNoPendingOrder(userId: string) { async function assertNoPendingOrder(userId: string) {
const pendingOrder = await prisma.order.findFirst({ const pendingOrder = await prisma.order.findFirst({
@@ -33,7 +34,7 @@ async function getProxyPlanForCart(planId: string) {
}, },
}); });
if (plan.type !== "PROXY") throw new Error(`套餐类型不匹配:${plan.name} ${plan.type},不能作为代理套餐加入购物车`); if (plan.type !== "PROXY") throw new Error(`套餐类型不匹配:${plan.name}${getSubscriptionTypeLabel(plan.type)},不能作为代理套餐加入购物车`);
if (!plan.isActive) throw new Error(`套餐已下架:${plan.name} 当前不可购买`); if (!plan.isActive) throw new Error(`套餐已下架:${plan.name} 当前不可购买`);
return plan; return plan;
} }
@@ -115,7 +116,7 @@ export async function addProxyPlanToCart(
export async function addStreamingPlanToCart(planId: string) { export async function addStreamingPlanToCart(planId: string) {
const session = await requireAuth(); const session = await requireAuth();
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({ where: { id: planId } }); const plan = await prisma.subscriptionPlan.findUniqueOrThrow({ where: { id: planId } });
if (plan.type !== "STREAMING") throw new Error(`套餐类型不匹配:${plan.name} ${plan.type},不能作为流媒体套餐加入购物车`); if (plan.type !== "STREAMING") throw new Error(`套餐类型不匹配:${plan.name}${getSubscriptionTypeLabel(plan.type)},不能作为流媒体套餐加入购物车`);
if (!plan.isActive) throw new Error(`套餐已下架:${plan.name} 当前不可购买`); if (!plan.isActive) throw new Error(`套餐已下架:${plan.name} 当前不可购买`);
const availability = await getPlanAvailability(plan, { userId: session.user.id }); const availability = await getPlanAvailability(plan, { userId: session.user.id });

View File

@@ -13,6 +13,7 @@ import {
getPlanTrafficPoolState, getPlanTrafficPoolState,
} from "@/services/plan-traffic-pool"; } from "@/services/plan-traffic-pool";
import { getPlanPurchasePrice, roundMoney } from "@/services/commerce"; import { getPlanPurchasePrice, roundMoney } from "@/services/commerce";
import { getSubscriptionTypeLabel } from "@/lib/domain-labels";
async function assertNoPendingOrder(userId: string) { async function assertNoPendingOrder(userId: string) {
const pendingOrder = await prisma.order.findFirst({ const pendingOrder = await prisma.order.findFirst({
@@ -133,7 +134,7 @@ export async function purchaseProxy(
}, },
}); });
if (plan.type !== "PROXY") throw new Error(`套餐类型不匹配:${plan.name} ${plan.type},不能作为代理套餐购买`); if (plan.type !== "PROXY") throw new Error(`套餐类型不匹配:${plan.name}${getSubscriptionTypeLabel(plan.type)},不能作为代理套餐购买`);
if (!plan.isActive) throw new Error(`套餐已下架:${plan.name} 当前不可购买`); if (!plan.isActive) throw new Error(`套餐已下架:${plan.name} 当前不可购买`);
const price = getPlanPurchasePrice(plan, trafficGb); const price = getPlanPurchasePrice(plan, trafficGb);
@@ -213,7 +214,7 @@ export async function purchaseStreaming(planId: string): Promise<string> {
where: { id: planId }, where: { id: planId },
}); });
if (plan.type !== "STREAMING") throw new Error(`套餐类型不匹配:${plan.name} ${plan.type},不能作为流媒体套餐购买`); if (plan.type !== "STREAMING") throw new Error(`套餐类型不匹配:${plan.name}${getSubscriptionTypeLabel(plan.type)},不能作为流媒体套餐购买`);
if (!plan.isActive) throw new Error(`套餐已下架:${plan.name} 当前不可购买`); if (!plan.isActive) throw new Error(`套餐已下架:${plan.name} 当前不可购买`);
const availability = await getPlanAvailability(plan, { userId: session.user.id }); const availability = await getPlanAvailability(plan, { userId: session.user.id });

View File

@@ -9,6 +9,13 @@ import {
DataTableHeaderRow, DataTableHeaderRow,
DataTableRow, DataTableRow,
} from "@/components/shared/data-table"; } from "@/components/shared/data-table";
import {
formatAuditAction,
formatAuditActorRole,
formatAuditMessage,
formatAuditTargetLabel,
formatAuditTargetType,
} from "@/lib/audit-display";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
export function AuditLogsTable({ logs }: { logs: AuditLog[] }) { export function AuditLogsTable({ logs }: { logs: AuditLog[] }) {
@@ -37,20 +44,22 @@ export function AuditLogsTable({ logs }: { logs: AuditLog[] }) {
<DataTableCell> <DataTableCell>
<div className="space-y-1"> <div className="space-y-1">
<p>{log.actorEmail || "系统"}</p> <p>{log.actorEmail || "系统"}</p>
<p className="text-xs text-muted-foreground">{log.actorRole || "—"}</p> <p className="text-xs text-muted-foreground">{formatAuditActorRole(log.actorRole)}</p>
</div> </div>
</DataTableCell> </DataTableCell>
<DataTableCell className="whitespace-nowrap font-medium">{log.action}</DataTableCell> <DataTableCell className="whitespace-nowrap font-medium">
{formatAuditAction(log.action)}
</DataTableCell>
<DataTableCell> <DataTableCell>
<div className="space-y-1"> <div className="space-y-1">
<p>{log.targetType}</p> <p>{formatAuditTargetType(log.targetType)}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{log.targetLabel || log.targetId || "—"} {formatAuditTargetLabel(log)}
</p> </p>
</div> </div>
</DataTableCell> </DataTableCell>
<DataTableCell className="max-w-xl whitespace-pre-wrap break-words text-muted-foreground"> <DataTableCell className="max-w-xl whitespace-pre-wrap break-words text-muted-foreground">
{log.message} {formatAuditMessage(log.message)}
</DataTableCell> </DataTableCell>
</DataTableRow> </DataTableRow>
))} ))}

View File

@@ -4,6 +4,7 @@ import { AdminFilterBar } from "@/components/admin/filter-bar";
import { PageHeader, PageShell } from "@/components/shared/page-shell"; import { PageHeader, PageShell } from "@/components/shared/page-shell";
import { Pagination } from "@/components/shared/pagination"; import { Pagination } from "@/components/shared/pagination";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
import { auditActionFilterOptions } from "@/lib/audit-display";
import { AuditLogsTable } from "./_components/audit-logs-table"; import { AuditLogsTable } from "./_components/audit-logs-table";
import { buildAuditLogExportHref, getAuditLogs } from "./audit-logs-data"; import { buildAuditLogExportHref, getAuditLogs } from "./audit-logs-data";
@@ -42,17 +43,7 @@ export default async function AuditLogsPage({
{ {
name: "action", name: "action",
value: filters.action, value: filters.action,
options: [ options: auditActionFilterOptions,
{ label: "全部动作前缀", value: "" },
{ label: "user.", value: "user." },
{ label: "order.", value: "order." },
{ label: "subscription.", value: "subscription." },
{ label: "plan.", value: "plan." },
{ label: "service.", value: "service." },
{ label: "node.", value: "node." },
{ label: "task.", value: "task." },
{ label: "risk.", value: "risk." },
],
}, },
]} ]}
/> />

View File

@@ -4,6 +4,7 @@ import { ArrowLeft } from "lucide-react";
import { PageHeader, PageShell } from "@/components/shared/page-shell"; import { PageHeader, PageShell } from "@/components/shared/page-shell";
import { StatusBadge } from "@/components/shared/status-badge"; import { StatusBadge } from "@/components/shared/status-badge";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
import { getNodeStatusLabel } from "@/lib/domain-labels";
import { getNodeDetail } from "./node-detail-data"; import { getNodeDetail } from "./node-detail-data";
import { NodeDetailTabs } from "./_components/node-detail-tabs"; import { NodeDetailTabs } from "./_components/node-detail-tabs";
@@ -34,7 +35,7 @@ export default async function NodeDetailPage({
description={`3x-ui · ${node.panelUrl || "未配置面板"}`} description={`3x-ui · ${node.panelUrl || "未配置面板"}`}
actions={ actions={
<StatusBadge tone={node.status === "active" ? "success" : "neutral"}> <StatusBadge tone={node.status === "active" ? "success" : "neutral"}>
{node.status} {getNodeStatusLabel(node.status)}
</StatusBadge> </StatusBadge>
} }
className="flex-1" className="flex-1"

View File

@@ -5,6 +5,7 @@ import { BatchActionBar, BatchActionButton } from "@/components/admin/batch-acti
import { EmptyState } from "@/components/shared/page-shell"; import { EmptyState } from "@/components/shared/page-shell";
import { StatusBadge } from "@/components/shared/status-badge"; import { StatusBadge } from "@/components/shared/status-badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getNodeStatusLabel } from "@/lib/domain-labels";
import { InboundDeleteButton } from "../inbound-delete-button"; import { InboundDeleteButton } from "../inbound-delete-button";
import { InboundDisplayNameForm } from "../inbound-display-name-form"; import { InboundDisplayNameForm } from "../inbound-display-name-form";
import { NodeActions } from "../node-actions"; import { NodeActions } from "../node-actions";
@@ -52,7 +53,7 @@ function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | nu
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<StatusBadge tone={node.status === "active" ? "success" : "neutral"}> <StatusBadge tone={node.status === "active" ? "success" : "neutral"}>
{node.status} {getNodeStatusLabel(node.status)}
</StatusBadge> </StatusBadge>
<NodeForm <NodeForm
node={{ node={{

View File

@@ -16,6 +16,10 @@ import {
} from "@/actions/admin/settings"; } from "@/actions/admin/settings";
import { toast } from "sonner"; import { toast } from "sonner";
import { getErrorMessage } from "@/lib/errors"; import { getErrorMessage } from "@/lib/errors";
import {
booleanAppSettingLabels,
type BooleanAppSettingField,
} from "@/lib/domain-labels";
interface AppConfig { interface AppConfig {
siteName: string; siteName: string;
@@ -72,23 +76,9 @@ interface CouponOption {
const selectClassName = "premium-input w-full appearance-none px-3.5 py-2 text-sm outline-none"; const selectClassName = "premium-input w-full appearance-none px-3.5 py-2 text-sm outline-none";
type ToggleValues = Record<BooleanSettingField, boolean>; type ToggleValues = Record<BooleanAppSettingField, boolean>;
const booleanSettingLabels: Record<BooleanSettingField, string> = { const booleanSettingLabels = booleanAppSettingLabels;
allowRegistration: "开放注册",
emailVerificationRequired: "注册邮箱验证",
requireInviteCode: "邀请码注册",
autoReminderDispatchEnabled: "自动提醒派发",
trafficSyncEnabled: "3x-ui 流量定时同步",
networkRecommendationsEnabled: "三网推荐",
networkInsightsEnabled: "线路体验",
subscriptionRiskEnabled: "风控总控",
subscriptionRiskAutoSuspend: "自动暂停",
nodeAccessRiskEnabled: "节点日志风控",
inviteRewardEnabled: "自动发放奖励",
smtpEnabled: "邮件服务",
smtpSecure: "SSL 直连",
};
function initialToggleValues(config: AppConfig): ToggleValues { function initialToggleValues(config: AppConfig): ToggleValues {
return { return {

View File

@@ -4,6 +4,7 @@ import { useEffect, useEffectEvent, useMemo, useState } from "react";
import { cancelOwnPendingOrder, resetOwnPendingPaymentChoice } from "@/actions/user/orders"; import { cancelOwnPendingOrder, resetOwnPendingPaymentChoice } from "@/actions/user/orders";
import { fetchJson } from "@/lib/fetch-json"; import { fetchJson } from "@/lib/fetch-json";
import { getErrorMessage } from "@/lib/errors"; import { getErrorMessage } from "@/lib/errors";
import { getOrderStatusLabel } from "@/lib/domain-labels";
import type { import type {
OrderPaymentSnapshot, OrderPaymentSnapshot,
PaymentInfo, PaymentInfo,
@@ -111,7 +112,7 @@ export function usePaymentFlow(orderId: string) {
if (order.status !== "PENDING") { if (order.status !== "PENDING") {
setStatus("idle"); setStatus("idle");
setPageError(`这笔订单当前为 ${order.status},无法继续支付。`); setPageError(`这笔订单当前为${getOrderStatusLabel(order.status)},无法继续支付。`);
return; return;
} }
@@ -161,7 +162,7 @@ export function usePaymentFlow(orderId: string) {
setStatus("idle"); setStatus("idle");
setPageError( setPageError(
result.error || `订单状态更新:${orderStatusLabel[result.status] ?? result.status}`, result.error || `订单状态更新:${orderStatusLabel[result.status] ?? "未知状态"}`,
); );
} catch (error) { } catch (error) {
setStatus("idle"); setStatus("idle");

View File

@@ -1,5 +1,12 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { requireAdminApiSession } from "@/lib/admin-api"; import { requireAdminApiSession } from "@/lib/admin-api";
import {
formatAuditAction,
formatAuditActorRole,
formatAuditMessage,
formatAuditTargetLabel,
formatAuditTargetType,
} from "@/lib/audit-display";
export async function GET(req: Request) { export async function GET(req: Request) {
const { errorResponse } = await requireAdminApiSession(); const { errorResponse } = await requireAdminApiSession();
@@ -9,9 +16,12 @@ export async function GET(req: Request) {
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const q = searchParams.get("q")?.trim() ?? ""; const q = searchParams.get("q")?.trim() ?? "";
const action = searchParams.get("action") ?? "";
const logs = await prisma.auditLog.findMany({ const logs = await prisma.auditLog.findMany({
where: q where: {
...(action ? { action: { startsWith: action } } : {}),
...(q
? { ? {
OR: [ OR: [
{ action: { contains: q } }, { action: { contains: q } },
@@ -21,12 +31,22 @@ export async function GET(req: Request) {
{ message: { contains: q } }, { message: { contains: q } },
], ],
} }
: undefined, : {}),
},
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
take: 5000, take: 5000,
}); });
return new Response(JSON.stringify(logs, null, 2), { const rows = logs.map((log) => ({
...log,
actorRoleLabel: formatAuditActorRole(log.actorRole),
actionLabel: formatAuditAction(log.action),
targetTypeLabel: formatAuditTargetType(log.targetType),
targetLabelDisplay: formatAuditTargetLabel(log),
messageDisplay: formatAuditMessage(log.message),
}));
return new Response(JSON.stringify(rows, null, 2), {
headers: { headers: {
"Content-Type": "application/json; charset=utf-8", "Content-Type": "application/json; charset=utf-8",
"Content-Disposition": 'attachment; filename="jboard-audit-logs.json"', "Content-Disposition": 'attachment; filename="jboard-audit-logs.json"',

View File

@@ -6,6 +6,7 @@ import { getPaymentAdapter } from "@/services/payment/factory";
import { rateLimit } from "@/lib/rate-limit"; import { rateLimit } from "@/lib/rate-limit";
import { getSiteBaseUrl } from "@/services/site-url"; import { getSiteBaseUrl } from "@/services/site-url";
import { getActiveSubscriptionRiskRestriction } from "@/services/subscription-risk-review"; import { getActiveSubscriptionRiskRestriction } from "@/services/subscription-risk-review";
import { getOrderStatusLabel } from "@/lib/domain-labels";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
const createPaymentSchema = z.object({ const createPaymentSchema = z.object({
@@ -66,7 +67,7 @@ export async function POST(req: Request) {
} }
if (order.status !== "PENDING") { if (order.status !== "PENDING") {
return jsonError(`订单当前状态为 ${order.status},无法继续支付`, { return jsonError(`订单当前状态为${getOrderStatusLabel(order.status)},无法继续支付`, {
status: 400, status: 400,
}); });
} }

View File

@@ -11,6 +11,7 @@ import { rateLimit } from "@/lib/rate-limit";
import { getClientRequestContext } from "@/lib/request-context"; import { getClientRequestContext } from "@/lib/request-context";
import { recordSubscriptionAccess } from "@/services/subscription-risk"; import { recordSubscriptionAccess } from "@/services/subscription-risk";
import { getAppConfig } from "@/services/app-config"; import { getAppConfig } from "@/services/app-config";
import { getSubscriptionStatusLabel } from "@/lib/domain-labels";
const SUBSCRIPTION_RATE_WINDOW_SECONDS = 60 * 60; const SUBSCRIPTION_RATE_WINDOW_SECONDS = 60 * 60;
@@ -60,7 +61,7 @@ export async function GET(
allowed: false, allowed: false,
reason: "rate_limited", reason: "rate_limited",
}); });
return jsonError("Too many subscription requests", 429); return jsonError("订阅请求过于频繁,请稍后再试", 429);
} }
} }
@@ -104,7 +105,7 @@ export async function GET(
allowed: false, allowed: false,
reason: "rate_limited", reason: "rate_limited",
}); });
return jsonError("Too many subscription requests", 429); return jsonError("订阅请求过于频繁,请稍后再试", 429);
} }
} }
@@ -117,7 +118,7 @@ export async function GET(
allowed: false, allowed: false,
reason: "subscription_inactive", reason: "subscription_inactive",
}); });
return jsonError(`订阅当前状态为 ${sub.status},只有 ACTIVE 状态可以拉取配置`, 403); return jsonError(`订阅当前状态为${getSubscriptionStatusLabel(sub.status)},只有活跃订阅可以拉取配置`, 403);
} }
const risk = await recordSubscriptionAccess({ const risk = await recordSubscriptionAccess({
@@ -130,7 +131,7 @@ export async function GET(
}); });
if (risk.suspended) { if (risk.suspended) {
return jsonError("Subscription suspended by risk control", 403); return jsonError("订阅已被风控暂停,请联系管理员处理", 403);
} }
const format = resolveSubscriptionFormat(url.searchParams, req.headers.get("user-agent")); const format = resolveSubscriptionFormat(url.searchParams, req.headers.get("user-agent"));

View File

@@ -12,6 +12,7 @@ import { rateLimit } from "@/lib/rate-limit";
import { getClientRequestContext } from "@/lib/request-context"; import { getClientRequestContext } from "@/lib/request-context";
import { recordSubscriptionAccess } from "@/services/subscription-risk"; import { recordSubscriptionAccess } from "@/services/subscription-risk";
import { getAppConfig } from "@/services/app-config"; import { getAppConfig } from "@/services/app-config";
import { getUserStatusLabel } from "@/lib/domain-labels";
const SUBSCRIPTION_RATE_WINDOW_SECONDS = 60 * 60; const SUBSCRIPTION_RATE_WINDOW_SECONDS = 60 * 60;
@@ -41,7 +42,7 @@ export async function GET(req: Request) {
allowed: false, allowed: false,
reason: "rate_limited", reason: "rate_limited",
}); });
return jsonError("Too many subscription requests", 429); return jsonError("订阅请求过于频繁,请稍后再试", 429);
} }
} }
@@ -87,7 +88,7 @@ export async function GET(req: Request) {
allowed: false, allowed: false,
reason: "rate_limited", reason: "rate_limited",
}); });
return jsonError("Too many subscription requests", 429); return jsonError("订阅请求过于频繁,请稍后再试", 429);
} }
} }
@@ -99,7 +100,7 @@ export async function GET(req: Request) {
allowed: false, allowed: false,
reason: "user_inactive", reason: "user_inactive",
}); });
return jsonError("User inactive", 403); return jsonError(`账户当前状态为${getUserStatusLabel(user.status)},暂时无法拉取总订阅`, 403);
} }
const risk = await recordSubscriptionAccess({ const risk = await recordSubscriptionAccess({
@@ -111,7 +112,7 @@ export async function GET(req: Request) {
}); });
if (risk.suspended) { if (risk.suspended) {
return jsonError("Subscriptions suspended by risk control", 403); return jsonError("总订阅已被风控暂停,请联系管理员处理", 403);
} }
const format = resolveSubscriptionFormat(url.searchParams, req.headers.get("user-agent")); const format = resolveSubscriptionFormat(url.searchParams, req.headers.get("user-agent"));

View File

@@ -1,85 +1,37 @@
import type { import type {
AnnouncementAudience, AnnouncementAudience,
AnnouncementDisplayType,
OrderKind,
OrderReviewStatus, OrderReviewStatus,
OrderStatus, OrderStatus,
Role, Role,
SubscriptionStatus, SubscriptionStatus,
SubscriptionType, SubscriptionType,
TaskKind,
TaskStatus, TaskStatus,
UserStatus, UserStatus,
} from "@prisma/client"; } from "@prisma/client";
import { StatusBadge, type StatusTone } from "@/components/shared/status-badge"; import { StatusBadge, type StatusTone } from "@/components/shared/status-badge";
import {
orderReviewStatusLabels,
orderStatusLabels,
subscriptionStatusLabels,
subscriptionTypeLabels,
taskStatusLabels,
userRoleLabels,
userStatusLabels,
} from "@/lib/domain-labels";
export const orderStatusLabels: Record<OrderStatus, string> = { export {
PENDING: "待确认", announcementAudienceLabels,
PAID: "已支付", announcementDisplayTypeLabels,
CANCELLED: "已取消", orderKindLabels,
REFUNDED: "已退款", orderReviewStatusLabels,
}; orderStatusLabels,
subscriptionStatusLabels,
export const orderKindLabels: Record<OrderKind, string> = { subscriptionTypeLabels,
NEW_PURCHASE: "新购", taskKindLabels,
RENEWAL: "续费", taskStatusLabels,
TRAFFIC_TOPUP: "增流量", userRoleLabels,
}; userStatusLabels,
} from "@/lib/domain-labels";
export const orderReviewStatusLabels: Record<OrderReviewStatus, string> = {
NORMAL: "正常",
FLAGGED: "异常",
RESOLVED: "已解决",
};
export const subscriptionStatusLabels: Record<SubscriptionStatus, string> = {
ACTIVE: "活跃",
EXPIRED: "已过期",
CANCELLED: "已取消",
SUSPENDED: "已暂停",
};
export const subscriptionTypeLabels: Record<SubscriptionType, string> = {
PROXY: "代理",
STREAMING: "流媒体",
};
export const userRoleLabels: Record<Role, string> = {
ADMIN: "管理员",
USER: "用户",
};
export const userStatusLabels: Record<UserStatus, string> = {
ACTIVE: "正常",
PENDING_EMAIL: "待邮箱验证",
DISABLED: "禁用",
BANNED: "封禁",
};
export const taskKindLabels: Record<TaskKind, string> = {
REMINDER_DISPATCH: "提醒派发",
ORDER_PROVISION_RETRY: "订单重试",
};
export const taskStatusLabels: Record<TaskStatus, string> = {
PENDING: "待执行",
RUNNING: "运行中",
SUCCESS: "成功",
FAILED: "失败",
};
export const announcementAudienceLabels: Record<AnnouncementAudience, string> = {
PUBLIC: "公开",
USERS: "全部用户",
ADMINS: "全部管理员",
SPECIFIC_USER: "指定用户",
};
export const announcementDisplayTypeLabels: Record<AnnouncementDisplayType, string> = {
INLINE: "普通公告",
BIG: "大公告",
POPUP: "弹窗公告",
};
export function getOrderStatusTone(status: OrderStatus): StatusTone { export function getOrderStatusTone(status: OrderStatus): StatusTone {
if (status === "PAID") return "success"; if (status === "PAID") return "success";

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { CircleDot } from "lucide-react"; import { CircleDot } from "lucide-react";
import { formatAuditAction, formatAuditMessage } from "@/lib/audit-display";
interface TimelineItem { interface TimelineItem {
id: string; id: string;
@@ -28,11 +29,11 @@ export function SubscriptionTimeline({
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm font-semibold text-pretty">{item.message}</p> <p className="text-sm font-semibold text-pretty">{formatAuditMessage(item.message)}</p>
<span className="shrink-0 text-xs text-muted-foreground">{item.createdAt}</span> <span className="shrink-0 text-xs text-muted-foreground">{item.createdAt}</span>
</div> </div>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
{item.action} {formatAuditAction(item.action)}
{item.actorEmail ? ` · ${item.actorEmail}` : " · 系统"} {item.actorEmail ? ` · ${item.actorEmail}` : " · 系统"}
</p> </p>
</div> </div>

View File

@@ -60,13 +60,15 @@ export function TrafficTrendChart({
<XAxis dataKey="date" /> <XAxis dataKey="date" />
<YAxis unit=" GB" width={60} /> <YAxis unit=" GB" width={60} />
<Tooltip <Tooltip
labelFormatter={(label) => `日期:${label}`}
formatter={(value) => formatter={(value) =>
`${Number(typeof value === "number" ? value : 0).toFixed(2)} GB` [`${Number(typeof value === "number" ? value : 0).toFixed(2)} GB`, "流量"]
} }
/> />
<Area <Area
type="monotone" type="monotone"
dataKey="valueGb" dataKey="valueGb"
name="流量"
stroke={color} stroke={color}
fill={color} fill={color}
fillOpacity={0.15} fillOpacity={0.15}

198
src/lib/audit-display.ts Normal file
View File

@@ -0,0 +1,198 @@
import {
booleanAppSettingLabels,
getBooleanAppSettingLabel,
getPaymentProviderLabel,
getTaskKindLabel,
getUserRoleLabel,
nodeStatusLabels,
orderStatusLabels,
paymentProviderLabels,
subscriptionStatusLabels,
subscriptionTypeLabels,
taskKindLabels,
userStatusLabels,
} from "@/lib/domain-labels";
export const auditActionFilterOptions = [
{ label: "全部动作", value: "" },
{ label: "用户操作", value: "user." },
{ label: "订单操作", value: "order." },
{ label: "订阅操作", value: "subscription." },
{ label: "套餐操作", value: "plan." },
{ label: "服务操作", value: "service." },
{ label: "节点操作", value: "node." },
{ label: "入站操作", value: "inbound." },
{ label: "任务操作", value: "task." },
{ label: "风控操作", value: "risk." },
{ label: "系统设置", value: "settings." },
{ label: "支付配置", value: "payment." },
{ label: "公告操作", value: "announcement." },
{ label: "工单操作", value: "support." },
{ label: "优惠规则", value: "coupon." },
{ label: "满减规则", value: "promotion." },
{ label: "备份恢复", value: "backup." },
{ label: "流量同步", value: "traffic." },
{ label: "流媒体槽位", value: "streaming-slot." },
];
const auditActionLabels: Record<string, string> = {
"announcement.create": "创建公告",
"announcement.update": "更新公告",
"announcement.enable": "启用公告",
"announcement.disable": "停用公告",
"announcement.delete": "删除公告",
"backup.restore": "恢复数据库",
"coupon.create": "创建优惠券",
"coupon.toggle": "切换优惠券状态",
"inbound.delete": "删除线路入口",
"inbound.display_name.update": "更新线路名称",
"node.create": "创建节点",
"node.update": "更新节点",
"node.delete": "删除节点",
"node.test": "同步节点入站",
"node.probe_token.generate": "生成探测 Token",
"node.probe_token.revoke": "撤销探测 Token",
"order.confirm": "确认订单",
"order.cancel": "取消订单",
"order.review": "更新订单审查",
"payment.config": "更新支付配置",
"plan.create": "创建套餐",
"plan.update": "更新套餐",
"plan.enable": "上架套餐",
"plan.disable": "下架套餐",
"plan.delete": "删除套餐",
"plan.batch_enable": "批量上架套餐",
"plan.batch_disable": "批量下架套餐",
"promotion.create": "创建满减规则",
"promotion.toggle": "切换满减规则状态",
"risk.node_access.suspend": "节点风控暂停订阅",
"risk.node_access.warning": "记录节点访问警告",
"risk.subscription.finalize": "完成风控处置",
"risk.subscription.report.generate": "生成风控报告",
"risk.subscription.report.send": "发送风控通知",
"risk.subscription.review": "更新订阅风控事件",
"risk.subscription.suspend": "风控暂停订阅",
"risk.subscription.warning": "记录订阅风险警告",
"service.create": "创建流媒体服务",
"service.update": "更新流媒体服务",
"service.delete": "删除流媒体服务",
"service.enable": "启用流媒体服务",
"service.disable": "停用流媒体服务",
"service.batch_enable": "批量启用流媒体服务",
"service.batch_disable": "批量停用流媒体服务",
"settings.toggle": "切换系统开关",
"settings.update": "更新系统设置",
"streaming-slot.reassign": "调配流媒体槽位",
"subscription.activate": "恢复订阅",
"subscription.auto_suspend": "自动暂停订阅",
"subscription.cancel": "取消订阅",
"subscription.create": "创建订阅",
"subscription.delete": "删除订阅",
"subscription.renew": "续费订阅",
"subscription.rotate_access": "重置订阅访问密钥",
"subscription.suspend": "暂停订阅",
"subscription.topup": "追加流量",
"support.close": "关闭工单",
"support.delete": "删除工单",
"support.reply": "回复工单",
"support.update": "更新工单",
"task.retry": "重试任务",
"task.run": "执行任务",
"traffic.sync": "同步流量视图",
"user.batch_status": "批量更新用户状态",
"user.create": "创建用户",
"user.force_delete": "强制删除用户",
"user.status": "更新用户状态",
"user.update": "更新用户",
};
const auditTargetTypeLabels: Record<string, string> = {
Announcement: "公告",
AppConfig: "系统设置",
Coupon: "优惠券",
Database: "数据库",
NodeInbound: "线路入口",
NodeServer: "节点",
Order: "订单",
PaymentConfig: "支付配置",
PromotionRule: "满减规则",
StreamingService: "流媒体服务",
StreamingSlot: "流媒体槽位",
SubscriptionPlan: "套餐",
SupportTicket: "工单",
TaskRun: "任务",
TrafficSync: "流量同步",
User: "用户",
UserSubscription: "订阅",
};
const tokenLabels: Record<string, string> = {
...booleanAppSettingLabels,
...nodeStatusLabels,
...orderStatusLabels,
...paymentProviderLabels,
...subscriptionStatusLabels,
...subscriptionTypeLabels,
...taskKindLabels,
...userStatusLabels,
};
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function replaceKnownTokens(value: string) {
return Object.entries(tokenLabels)
.sort(([left], [right]) => right.length - left.length)
.reduce((text, [token, label]) => {
const pattern = new RegExp(`\\b${escapeRegExp(token)}\\b`, "g");
return text.replace(pattern, label);
}, value);
}
export function formatAuditAction(action: string) {
const exact = auditActionLabels[action];
if (exact) return exact;
const prefix = auditActionFilterOptions.find((option) => option.value && action.startsWith(option.value));
return prefix?.label ?? "系统操作";
}
export function formatAuditTargetType(targetType: string | null | undefined) {
if (!targetType) return "系统";
return auditTargetTypeLabels[targetType] ?? "业务对象";
}
export function formatAuditTargetLabel({
targetType,
targetLabel,
targetId,
}: {
targetType: string | null | undefined;
targetLabel?: string | null;
targetId?: string | null;
}) {
if (targetLabel) {
if (targetType === "PaymentConfig") return getPaymentProviderLabel(targetLabel);
if (targetType === "TaskRun") return getTaskKindLabel(targetLabel);
return replaceKnownTokens(targetLabel);
}
if (targetType === "Database") return "数据库";
if (targetType === "TrafficSync") return "全站流量";
if (targetId) return `ID ${targetId.slice(0, 8)}`;
return "—";
}
export function formatAuditActorRole(role: string | null | undefined) {
if (!role) return "系统";
return getUserRoleLabel(role);
}
export function formatAuditMessage(message: string) {
const withSettingLabels = message.replace(
/系统开关\s+([A-Za-z][A-Za-z0-9_]*)/g,
(_match, field: string) => `${getBooleanAppSettingLabel(field)}开关`,
);
return replaceKnownTokens(withSettingLabels);
}

168
src/lib/domain-labels.ts Normal file
View File

@@ -0,0 +1,168 @@
import type {
AnnouncementAudience,
AnnouncementDisplayType,
OrderKind,
OrderReviewStatus,
OrderStatus,
Role,
SubscriptionStatus,
SubscriptionType,
TaskKind,
TaskStatus,
UserStatus,
} from "@prisma/client";
export const orderStatusLabels: Record<OrderStatus, string> = {
PENDING: "待确认",
PAID: "已支付",
CANCELLED: "已取消",
REFUNDED: "已退款",
};
export const orderKindLabels: Record<OrderKind, string> = {
NEW_PURCHASE: "新购",
RENEWAL: "续费",
TRAFFIC_TOPUP: "增流量",
};
export const orderReviewStatusLabels: Record<OrderReviewStatus, string> = {
NORMAL: "正常",
FLAGGED: "异常",
RESOLVED: "已解决",
};
export const subscriptionStatusLabels: Record<SubscriptionStatus, string> = {
ACTIVE: "活跃",
EXPIRED: "已过期",
CANCELLED: "已取消",
SUSPENDED: "已暂停",
};
export const subscriptionTypeLabels: Record<SubscriptionType, string> = {
PROXY: "代理",
STREAMING: "流媒体",
};
export const userRoleLabels: Record<Role, string> = {
ADMIN: "管理员",
USER: "用户",
};
export const userStatusLabels: Record<UserStatus, string> = {
ACTIVE: "正常",
PENDING_EMAIL: "待邮箱验证",
DISABLED: "禁用",
BANNED: "封禁",
};
export const taskKindLabels: Record<TaskKind, string> = {
REMINDER_DISPATCH: "提醒派发",
ORDER_PROVISION_RETRY: "订单重试",
};
export const taskStatusLabels: Record<TaskStatus, string> = {
PENDING: "待执行",
RUNNING: "运行中",
SUCCESS: "成功",
FAILED: "失败",
};
export const announcementAudienceLabels: Record<AnnouncementAudience, string> = {
PUBLIC: "公开",
USERS: "全部用户",
ADMINS: "全部管理员",
SPECIFIC_USER: "指定用户",
};
export const announcementDisplayTypeLabels: Record<AnnouncementDisplayType, string> = {
INLINE: "普通公告",
BIG: "大公告",
POPUP: "弹窗公告",
};
export const booleanAppSettingLabels = {
allowRegistration: "开放注册",
emailVerificationRequired: "注册邮箱验证",
requireInviteCode: "邀请码注册",
autoReminderDispatchEnabled: "自动提醒派发",
trafficSyncEnabled: "3x-ui 流量定时同步",
networkRecommendationsEnabled: "三网推荐",
networkInsightsEnabled: "线路体验",
subscriptionRiskEnabled: "订阅访问风控",
subscriptionRiskAutoSuspend: "风控自动暂停",
nodeAccessRiskEnabled: "节点日志风控",
inviteRewardEnabled: "自动发放奖励",
smtpEnabled: "邮件服务",
smtpSecure: "SMTP SSL 直连",
} as const;
export type BooleanAppSettingField = keyof typeof booleanAppSettingLabels;
export const booleanAppSettingFields = Object.keys(booleanAppSettingLabels) as [
BooleanAppSettingField,
...BooleanAppSettingField[],
];
export const nodeStatusLabels: Record<string, string> = {
active: "已启用",
inactive: "已停用",
disabled: "已停用",
error: "异常",
offline: "离线",
};
export const paymentProviderLabels: Record<string, string> = {
epay: "易支付",
alipay_f2f: "支付宝当面付",
usdt_trc20: "USDT (TRC20)",
};
export const paymentChannelLabels: Record<string, string> = {
alipay: "支付宝",
wxpay: "微信支付",
};
function labelFromMap(map: Partial<Record<string, string>>, value: string | null | undefined, fallback: string) {
if (!value) return fallback;
return map[value] ?? fallback;
}
export function getOrderStatusLabel(status: string | null | undefined) {
return labelFromMap(orderStatusLabels, status, "未知订单状态");
}
export function getOrderKindLabel(kind: string | null | undefined) {
return labelFromMap(orderKindLabels, kind, "未知订单类型");
}
export function getSubscriptionStatusLabel(status: string | null | undefined) {
return labelFromMap(subscriptionStatusLabels, status, "未知订阅状态");
}
export function getSubscriptionTypeLabel(type: string | null | undefined) {
return labelFromMap(subscriptionTypeLabels, type, "未知套餐类型");
}
export function getUserStatusLabel(status: string | null | undefined) {
return labelFromMap(userStatusLabels, status, "未知用户状态");
}
export function getUserRoleLabel(role: string | null | undefined) {
return labelFromMap(userRoleLabels, role, "未知角色");
}
export function getTaskKindLabel(kind: string | null | undefined) {
return labelFromMap(taskKindLabels, kind, "任务");
}
export function getNodeStatusLabel(status: string | null | undefined) {
return labelFromMap(nodeStatusLabels, status, "未知状态");
}
export function getPaymentProviderLabel(provider: string | null | undefined) {
return labelFromMap(paymentProviderLabels, provider, "支付方式");
}
export function getBooleanAppSettingLabel(field: string | null | undefined) {
return labelFromMap(booleanAppSettingLabels, field, "系统开关");
}

View File

@@ -200,7 +200,7 @@ export function parsePaymentConfig(
const schema = paymentConfigSchemas[provider as keyof typeof paymentConfigSchemas]; const schema = paymentConfigSchemas[provider as keyof typeof paymentConfigSchemas];
if (!schema) { if (!schema) {
throw new Error(`未知支付方式${provider}`); throw new Error("未知支付方式");
} }
return schema.parse(normalized); return schema.parse(normalized);

View File

@@ -18,7 +18,7 @@ export async function getPaymentAdapter(provider: string): Promise<PaymentAdapte
}); });
if (!config || !config.enabled) { if (!config || !config.enabled) {
throw new Error(`Payment provider "${provider}" is not configured or disabled`); throw new Error(`${getPaymentProviderName(realProvider)}未配置或未启用`);
} }
const cfg = parsePaymentConfig( const cfg = parsePaymentConfig(
@@ -34,7 +34,7 @@ export async function getPaymentAdapter(provider: string): Promise<PaymentAdapte
case "usdt_trc20": case "usdt_trc20":
return new UsdtTrc20Adapter(cfg as UsdtTrc20Config); return new UsdtTrc20Adapter(cfg as UsdtTrc20Config);
default: default:
throw new Error(`Unknown payment provider: ${provider}`); throw new Error("未知支付方式");
} }
} }

View File

@@ -9,6 +9,7 @@ import { recordAuditLog } from "@/services/audit";
import { createNotification } from "@/services/notifications"; import { createNotification } from "@/services/notifications";
import { generateNodeClientCredential } from "@/services/node-client-credential"; import { generateNodeClientCredential } from "@/services/node-client-credential";
import { createPanelAdapter } from "@/services/node-panel/factory"; import { createPanelAdapter } from "@/services/node-panel/factory";
import { getOrderKindLabel, getSubscriptionStatusLabel } from "@/lib/domain-labels";
import type { import type {
NodeServer, NodeServer,
Order, Order,
@@ -42,7 +43,7 @@ export async function provisionSubscriptionWithDb(
return applyTrafficTopup(order, db); return applyTrafficTopup(order, db);
} }
throw new Error(`开通订阅失败:不支持的订单类型 ${String(order.kind)}`); throw new Error(`开通订阅失败:不支持的订单类型${getOrderKindLabel(order.kind)}`);
} }
async function getNewPurchaseItems(order: PaidOrder, db: DbClient): Promise<NewOrderItem[]> { async function getNewPurchaseItems(order: PaidOrder, db: DbClient): Promise<NewOrderItem[]> {
@@ -191,7 +192,7 @@ async function applyRenewal(order: PaidOrder, db: DbClient): Promise<string[]> {
throw new Error("续费目标订阅与订单不匹配"); throw new Error("续费目标订阅与订单不匹配");
} }
if (subscription.status !== "ACTIVE" || subscription.endDate <= new Date()) { if (subscription.status !== "ACTIVE" || subscription.endDate <= new Date()) {
throw new Error(`续费失败:目标订阅状态为 ${subscription.status},到期时间为 ${subscription.endDate.toISOString()}`); throw new Error(`续费失败:目标订阅状态为${getSubscriptionStatusLabel(subscription.status)},到期时间为 ${subscription.endDate.toISOString()}`);
} }
const now = new Date(); const now = new Date();

View File

@@ -6,6 +6,7 @@ import type {
} from "@prisma/client"; } from "@prisma/client";
import { prisma, type DbClient } from "@/lib/prisma"; import { prisma, type DbClient } from "@/lib/prisma";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
import { getSubscriptionStatusLabel, getSubscriptionTypeLabel } from "@/lib/domain-labels";
export const subscriptionRiskAccessLogSelect = { export const subscriptionRiskAccessLogSelect = {
id: true, id: true,
@@ -370,7 +371,7 @@ export function buildSubscriptionRiskReport(input: {
const { event, logs, user, subscription } = input; const { event, logs, user, subscription } = input;
const summary = buildSubscriptionRiskGeoSummary(logs); const summary = buildSubscriptionRiskGeoSummary(logs);
const target = subscription const target = subscription
? `${subscription.plan.name}${subscription.plan.type},当前状态:${subscription.status}` ? `${subscription.plan.name}${getSubscriptionTypeLabel(subscription.plan.type)},当前状态:${getSubscriptionStatusLabel(subscription.status)}`
: "用户总订阅"; : "用户总订阅";
const userLabel = user ? `${user.email}${user.name ? `${user.name}` : ""}` : event.userId ?? "未知用户"; const userLabel = user ? `${user.email}${user.name ? `${user.name}` : ""}` : event.userId ?? "未知用户";
const windowRange = `${formatDate(event.windowStartedAt)}${formatDate(event.createdAt)}`; const windowRange = `${formatDate(event.windowStartedAt)}${formatDate(event.createdAt)}`;