feat: 订单来源追踪,保存 src_host / src_url 到订单记录
iframe 嵌入充值页面时 URL 自动附带来源参数,写入数据库用于追踪分析。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "orders" ADD COLUMN "src_host" TEXT,
|
||||||
|
ADD COLUMN "src_url" TEXT;
|
||||||
@@ -34,6 +34,8 @@ model Order {
|
|||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
clientIp String? @map("client_ip")
|
clientIp String? @map("client_ip")
|
||||||
|
srcHost String? @map("src_host")
|
||||||
|
srcUrl String? @map("src_url")
|
||||||
|
|
||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ interface AdminOrderDetail extends AdminOrder {
|
|||||||
failedAt: string | null;
|
failedAt: string | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
clientIp: string | null;
|
clientIp: string | null;
|
||||||
|
srcHost: string | null;
|
||||||
|
srcUrl: string | null;
|
||||||
paymentSuccess?: boolean;
|
paymentSuccess?: boolean;
|
||||||
rechargeSuccess?: boolean;
|
rechargeSuccess?: boolean;
|
||||||
rechargeStatus?: string;
|
rechargeStatus?: string;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ const createOrderSchema = z.object({
|
|||||||
user_id: z.number().int().positive(),
|
user_id: z.number().int().positive(),
|
||||||
amount: z.number().positive(),
|
amount: z.number().positive(),
|
||||||
payment_type: z.enum(['alipay', 'wxpay', 'stripe']),
|
payment_type: z.enum(['alipay', 'wxpay', 'stripe']),
|
||||||
|
src_host: z.string().optional(),
|
||||||
|
src_url: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -19,7 +21,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: '参数错误', details: parsed.error.flatten().fieldErrors }, { status: 400 });
|
return NextResponse.json({ error: '参数错误', details: parsed.error.flatten().fieldErrors }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { user_id, amount, payment_type } = parsed.data;
|
const { user_id, amount, payment_type, src_host, src_url } = parsed.data;
|
||||||
|
|
||||||
// Validate amount range
|
// Validate amount range
|
||||||
if (amount < env.MIN_RECHARGE_AMOUNT || amount > env.MAX_RECHARGE_AMOUNT) {
|
if (amount < env.MIN_RECHARGE_AMOUNT || amount > env.MAX_RECHARGE_AMOUNT) {
|
||||||
@@ -42,6 +44,8 @@ export async function POST(request: NextRequest) {
|
|||||||
amount,
|
amount,
|
||||||
paymentType: payment_type,
|
paymentType: payment_type,
|
||||||
clientIp,
|
clientIp,
|
||||||
|
srcHost: src_host,
|
||||||
|
srcUrl: src_url,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 不向客户端暴露 userName / userBalance 等隐私字段
|
// 不向客户端暴露 userName / userBalance 等隐私字段
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ function PayContent() {
|
|||||||
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
|
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
|
||||||
const uiMode = searchParams.get('ui_mode') || 'standalone';
|
const uiMode = searchParams.get('ui_mode') || 'standalone';
|
||||||
const tab = searchParams.get('tab');
|
const tab = searchParams.get('tab');
|
||||||
|
const srcHost = searchParams.get('src_host') || undefined;
|
||||||
|
const srcUrl = searchParams.get('src_url') || undefined;
|
||||||
const isDark = theme === 'dark';
|
const isDark = theme === 'dark';
|
||||||
|
|
||||||
const [isIframeContext, setIsIframeContext] = useState(true);
|
const [isIframeContext, setIsIframeContext] = useState(true);
|
||||||
@@ -230,6 +232,8 @@ function PayContent() {
|
|||||||
user_id: effectiveUserId,
|
user_id: effectiveUserId,
|
||||||
amount,
|
amount,
|
||||||
payment_type: paymentType,
|
payment_type: paymentType,
|
||||||
|
src_host: srcHost,
|
||||||
|
src_url: srcUrl,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ interface OrderDetailProps {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
clientIp: string | null;
|
clientIp: string | null;
|
||||||
|
srcHost: string | null;
|
||||||
|
srcUrl: string | null;
|
||||||
paymentSuccess?: boolean;
|
paymentSuccess?: boolean;
|
||||||
rechargeSuccess?: boolean;
|
rechargeSuccess?: boolean;
|
||||||
rechargeStatus?: string;
|
rechargeStatus?: string;
|
||||||
@@ -54,6 +56,8 @@ export default function OrderDetail({ order, onClose }: OrderDetailProps) {
|
|||||||
{ label: '充值码', value: order.rechargeCode },
|
{ label: '充值码', value: order.rechargeCode },
|
||||||
{ label: '支付单号', value: order.paymentTradeNo || '-' },
|
{ label: '支付单号', value: order.paymentTradeNo || '-' },
|
||||||
{ label: '客户端IP', value: order.clientIp || '-' },
|
{ label: '客户端IP', value: order.clientIp || '-' },
|
||||||
|
{ label: '来源域名', value: order.srcHost || '-' },
|
||||||
|
{ label: '来源页面', value: order.srcUrl || '-' },
|
||||||
{ label: '创建时间', value: new Date(order.createdAt).toLocaleString('zh-CN') },
|
{ label: '创建时间', value: new Date(order.createdAt).toLocaleString('zh-CN') },
|
||||||
{ label: '过期时间', value: new Date(order.expiresAt).toLocaleString('zh-CN') },
|
{ label: '过期时间', value: new Date(order.expiresAt).toLocaleString('zh-CN') },
|
||||||
{ label: '支付时间', value: order.paidAt ? new Date(order.paidAt).toLocaleString('zh-CN') : '-' },
|
{ label: '支付时间', value: order.paidAt ? new Date(order.paidAt).toLocaleString('zh-CN') : '-' },
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface CreateOrderInput {
|
|||||||
amount: number;
|
amount: number;
|
||||||
paymentType: PaymentType;
|
paymentType: PaymentType;
|
||||||
clientIp: string;
|
clientIp: string;
|
||||||
|
srcHost?: string;
|
||||||
|
srcUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateOrderResult {
|
export interface CreateOrderResult {
|
||||||
@@ -106,6 +108,8 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
|||||||
paymentType: input.paymentType,
|
paymentType: input.paymentType,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
clientIp: input.clientIp,
|
clientIp: input.clientIp,
|
||||||
|
srcHost: input.srcHost || null,
|
||||||
|
srcUrl: input.srcUrl || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user